diff --git a/src/server/app.js b/src/server/app.js index eb9f114..9d5d199 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -398,10 +398,11 @@ export function createApp(ctx) { router.on('POST', '/api/tasks/:id/submit', async ({ req, params }) => { const user = auth(req); - submitLimiter.check(user.id); + submitLimiter.ensure(user.id); const taskId = intParam(params.id); const body = await readJson(req); const r = submitBatch(user, [taskId], body); + submitLimiter.record(user.id); return guided({ attemptId: r.attempts[0].attemptId, batchId: r.batchId, state: 'queued' }); }); @@ -460,9 +461,10 @@ export function createApp(ctx) { router.on('POST', '/api/batches', async ({ req }) => { const user = auth(req); - submitLimiter.check(user.id); + submitLimiter.ensure(user.id); const body = await readJson(req, 8 * 1024 * 1024); const r = submitBatch(user, (body.taskIds ?? []).map((x) => intParam(x)), body); + submitLimiter.record(user.id); return guided({ ...r, state: 'queued' }); }); diff --git a/src/server/http.js b/src/server/http.js index 91c69d7..8466178 100644 --- a/src/server/http.js +++ b/src/server/http.js @@ -78,4 +78,22 @@ export class RateLimiter { if (e.n > this.max) throw new HttpError(429, 'rate limit exceeded, try again later'); if (this.hits.size > 10000) for (const [k, v] of this.hits) if (t - v.start > this.windowMs) this.hits.delete(k); } + + // For limits on work that can be refused for other reasons: `ensure` before, `record` only once + // it was accepted, so a client retrying a refused request does not use up its budget. + ensure(key) { + const e = this.hits.get(key); + const t = Date.now(); + if (e && t - e.start <= this.windowMs && e.n >= this.max) { + const wait = Math.ceil((e.start + this.windowMs - t) / 1000); + throw new HttpError(429, `rate limit exceeded (${this.max} per ${Math.round(this.windowMs / 60_000)} minutes); try again in ${Math.ceil(wait / 60)} minute(s)`, { retryAfterSeconds: wait }); + } + } + + record(key) { + const t = Date.now(); + let e = this.hits.get(key); + if (!e || t - e.start > this.windowMs) this.hits.set(key, (e = { start: t, n: 0 })); + e.n++; + } } diff --git a/src/server/tasks.js b/src/server/tasks.js index 66a9b18..801bcaa 100644 --- a/src/server/tasks.js +++ b/src/server/tasks.js @@ -297,7 +297,7 @@ export class Tasks { const max = this.config.maxQueuedBatches ?? 4; const waiting = this.store.get(`SELECT COUNT(*) AS n FROM batches WHERE user_id = ? AND state IN ('queued', 'verifying')`, user.id).n; if (waiting >= max) { - throw new HttpError(429, `you have ${waiting} batches waiting for verification (the limit is ${max}); keep your claims, do peer reviews meanwhile (td_list_peer_reviews), and submit again when one is done`); + throw new HttpError(429, `you have ${waiting} batches waiting for verification (the limit is ${max}); keep your claims, do peer reviews meanwhile (td_list_peer_reviews), and submit again when one is done (td_get_batch shows it)`, { retryAfterSeconds: 120 }); } const b = this.store.run( `INSERT INTO batches (user_id, base_commit, files_json, notes, agent_meta, env_digest, state, created_at) VALUES (?, ?, ?, ?, ?, ?, 'queued', ?)`,