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:
Jakub Zych
2026-11-09 22:48:50 +01:00
parent 34b0966819
commit bb18242af9
5 changed files with 61 additions and 5 deletions

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)},
}
}

View File

@@ -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]+`)