fix: throttle student answers and attempt starts per user
WR-03: every new or changed open answer in a learning quiz with a grader is a paid grading call. Add per-user buckets golem15.quizzes.answer (60/min) and golem15.quizzes.start (10/min), configurable under golem15.quizzes.throttle.*.
This commit is contained in:
@@ -366,6 +366,8 @@ Defaults live in `config/config.yaml` and are merged under `golem15.quizzes.*`.
|
||||
| `golem15.quizzes.scoring.multiple_partial` | `false` | Partial credit on `multiple` questions: `(correct chosen - wrong chosen) / correct answers` of the points, floored at 0. Without it only the exact set earns points. |
|
||||
| `golem15.quizzes.scoring.penalty_wrong` | `0` | Fraction (0 to 1) of a question's points subtracted for a wrong answer: a non-empty `single` or `multiple` answer that earns nothing. `self_check` answers marked as unknown and empty answers are never penalised. An attempt's total never goes below 0. |
|
||||
| `golem15.quizzes.test.grace_seconds` | `5` | Seconds after a test's time limit during which an answer still in flight is accepted, clamped to 0..120. A missing or non-numeric value is 5. Learning quizzes have no grace. |
|
||||
| `golem15.quizzes.throttle.answers_per_minute` | `60` | Per-user limit of `POST /attempts/{id}/answers` (bucket `golem15.quizzes.answer`). With a grader such as `ai`, every new or changed open answer in a learning quiz is a paid grading call. A missing, non-numeric or sub-1 value is 60. |
|
||||
| `golem15.quizzes.throttle.attempts_per_minute` | `10` | Per-user limit of `POST /quizzes/{slug}/attempts` (bucket `golem15.quizzes.start`). A missing, non-numeric or sub-1 value is 10. |
|
||||
|
||||
Scores are rounded to two decimals.
|
||||
|
||||
|
||||
@@ -52,6 +52,36 @@ func TestGraceFrom(cfg *compass.Config) time.Duration {
|
||||
return min(max(grace, 0), maxTestGrace)
|
||||
}
|
||||
|
||||
// Default per-user rate limits of the student write routes, per minute. In
|
||||
// a learning quiz with a grader every new or changed open answer is a paid
|
||||
// grading call, so answering and starting attempts are throttled (WR-03).
|
||||
const (
|
||||
DefaultAnswersPerMinute = 60
|
||||
DefaultAttemptsPerMinute = 10
|
||||
)
|
||||
|
||||
// maxPerMinute caps a configured per-minute limit.
|
||||
const maxPerMinute = 10000
|
||||
|
||||
// PerMinuteFrom reads a per-minute rate limit at key (for example
|
||||
// golem15.quizzes.throttle.answers_per_minute). A nil config, a missing
|
||||
// key or a value that is not a number of at least 1 give def; the value
|
||||
// is truncated to a whole number and capped at 10000.
|
||||
func PerMinuteFrom(cfg *compass.Config, key string, def int) int {
|
||||
if cfg == nil {
|
||||
return def
|
||||
}
|
||||
v, ok := cfg.Lookup(key)
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
n, ok := number(v)
|
||||
if !ok || n < 1 || math.IsInf(n, 0) {
|
||||
return def
|
||||
}
|
||||
return int(min(n, maxPerMinute))
|
||||
}
|
||||
|
||||
// number is v as a float when it is a number or a numeric string.
|
||||
func number(v any) (float64, bool) {
|
||||
switch n := v.(type) {
|
||||
|
||||
@@ -12,3 +12,9 @@ test:
|
||||
# Seconds after a test's time limit during which an answer still in flight
|
||||
# is accepted (0 to 120). Learning quizzes have no grace.
|
||||
grace_seconds: 5
|
||||
throttle:
|
||||
# Per-user limits of the student's answers and attempt starts, per minute.
|
||||
# With a grader such as ai, every new or changed open answer in a learning
|
||||
# quiz is a paid grading call, so keep these low.
|
||||
answers_per_minute: 60
|
||||
attempts_per_minute: 10
|
||||
|
||||
14
plugin.go
14
plugin.go
@@ -226,13 +226,19 @@ func (p *Plugin) FrontendPermissions() []pact.FrontendPermission {
|
||||
}}
|
||||
}
|
||||
|
||||
// Buckets declares the per-user rate limit of the owner manage routes:
|
||||
// building a quiz question by question is chatty, so 120 calls a minute.
|
||||
// A request without a user (never after user.api_token) keys by client IP.
|
||||
// Buckets declares the per-user rate limits: the owner manage routes
|
||||
// (building a quiz question by question is chatty, so 120 calls a minute),
|
||||
// and the student's answers and attempt starts, configurable as
|
||||
// golem15.quizzes.throttle.answers_per_minute and attempts_per_minute. A
|
||||
// request without a user (never after user.api_token or jwt.auth) keys by
|
||||
// client IP.
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(nil)
|
||||
answers, starts := classes.DefaultAnswersPerMinute, classes.DefaultAttemptsPerMinute
|
||||
if p != nil && p.app != nil {
|
||||
trusted = surf.TrustedProxies(p.app.Config)
|
||||
answers = classes.PerMinuteFrom(p.app.Config, "golem15.quizzes.throttle.answers_per_minute", answers)
|
||||
starts = classes.PerMinuteFrom(p.app.Config, "golem15.quizzes.throttle.attempts_per_minute", starts)
|
||||
}
|
||||
byUser := func(prefix string) func(*http.Request) string {
|
||||
return func(r *http.Request) string {
|
||||
@@ -244,6 +250,8 @@ func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
}
|
||||
return map[string]surf.Bucket{
|
||||
bucketManage: {Max: 120, Decay: time.Minute, Key: byUser(bucketManage)},
|
||||
bucketAnswer: {Max: answers, Decay: time.Minute, Key: byUser(bucketAnswer)},
|
||||
bucketStart: {Max: starts, Decay: time.Minute, Key: byUser(bucketStart)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
routes.go
14
routes.go
@@ -24,11 +24,21 @@ const permQuizzes = "golem15.quizzes.access_quizzes"
|
||||
// after user.api_token, so it keys by user.
|
||||
const bucketManage = "golem15.quizzes.manage"
|
||||
|
||||
// bucketAnswer and bucketStart limit the student's answers and attempt
|
||||
// starts. They run after jwt.auth, so they key by user. In a learning quiz
|
||||
// with a grader every new or changed open answer is a paid grading call,
|
||||
// and every new attempt starts a fresh answer row (WR-03).
|
||||
const (
|
||||
bucketAnswer = "golem15.quizzes.answer"
|
||||
bucketStart = "golem15.quizzes.start"
|
||||
)
|
||||
|
||||
// Routes mounts the quiz API under /_quizzes/api/v1 behind golem15.user's
|
||||
// jwt.auth middleware, and the management API under /api/v1/quizzes behind
|
||||
// its application-wide API token: the import route, and the owner manage
|
||||
// routes the MCP quiz tools bind, which also need the access_quizzes
|
||||
// permission and run under the per-user golem15.quizzes.manage throttle.
|
||||
// Answering and starting an attempt run under per-user throttles too.
|
||||
// The owner review routes under /api/v1/quiz-reviews (the MCP review
|
||||
// tools) run behind the same middleware and permission (D-13).
|
||||
func (p *Plugin) Routes(r pact.Router) error {
|
||||
@@ -37,12 +47,12 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("/quizzes", h.ListQuizzes)
|
||||
g.Get("/quizzes/{slug}", h.ShowQuiz)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Post("/quizzes/{slug}/attempts", h.StartAttempt)
|
||||
g.Post("/quizzes/{slug}/attempts", h.StartAttempt, "throttle:"+bucketStart)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Get("/attempts", h.ListAttempts)
|
||||
g.Get("/attempts/{id}", h.ShowAttempt)
|
||||
g.Where("id", `[0-9]+`)
|
||||
g.Post("/attempts/{id}/answers", h.Answer)
|
||||
g.Post("/attempts/{id}/answers", h.Answer, "throttle:"+bucketAnswer)
|
||||
g.Where("id", `[0-9]+`)
|
||||
g.Post("/attempts/{id}/finish", h.Finish)
|
||||
g.Where("id", `[0-9]+`)
|
||||
|
||||
Reference in New Issue
Block a user