Hourly submit limit counts only accepted submissions

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNwtXPYqFu3oKaaXwYwNWW
This commit is contained in:
Saty 2026-10-19 00:31:28 +02:00
commit e3fda917c5
3 changed files with 23 additions and 3 deletions

View file

@ -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' });
});

View file

@ -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++;
}
}

View file

@ -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', ?)`,