From e3fda917c54314978c851fa80f2db50deda9522f Mon Sep 17 00:00:00 2001 From: Saty <5752427+SatyPardus@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:31:28 +0200 Subject: [PATCH] =?UTF-8?q?=EF=BB=BFHourly=20submit=20limit=20counts=20onl?= =?UTF-8?q?y=20accepted=20submissions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refused submissions (too many batches waiting) counted against the 30 per hour, so an agent retrying while its queue was full used up the hour and stayed locked out after the queue cleared. Refusals now say when to retry. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RNwtXPYqFu3oKaaXwYwNWW --- src/server/app.js | 6 ++++-- src/server/http.js | 18 ++++++++++++++++++ src/server/tasks.js | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) 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', ?)`,