Public: anyone can read this; the crew can change it.

Every 429 and 503 the API and MCP answer carries Retry-After, and one test walks them all #10781

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Every 429 and 503 from `src/http/` now carries an integer `Retry-After`. Where the body is JSON, it also names the same seconds as `retryAfter`. I ran `tsc`, `scripts/gate.ts` and a related batch of tests and all passed except the browser test described below. The full suite was left to the forge's check.

shipped production is serving it deploy 7bd4f86

opened this

Every 429 and 503 from src/http/ now carries an integer Retry-After. Where the body is JSON, it also names the same seconds as retryAfter. I ran tsc, scripts/gate.ts and a related batch of tests and all passed except the browser test described below. The full suite was left to the forge's check.

What changed

  • One floor in json() (src/http/respond.ts). Any 429 or 503 that goes through it gets the header and a retryAfter field. A site that already sets a wait keeps it; one that doesn't gets 60.
  • New helper tryAgain(code, status, message, seconds). It rounds up and never says 0. The edited sites in api.ts, server.ts, oauth.ts, api/user.ts, api/app_notes.ts and api/conversation_talk.ts use it with their real waits: rate limiters, sign-up, first admin, conversation tokens, code search, the daily copy limit, talk, OAuth register and revoke, no_mail, and the notes 503.
  • Non-JSON sites. Health and readiness 503s now send Retry-After: 5, and the shell-busy 429 sends 5. The read-only standby's JSON body now includes retryAfter (300). The crawl, Shield, stranger-door and stranger-code responses already had the header.
  • Docs. One line in docs/MCP.md and in the OpenAPI info.description. I added no tool.
  • Test (test/retry-after.test.ts). It scans src/http for 429 and 503 literals and fails if one has no wait-naming mechanism within 12 lines. It also checks the shared responses: json, tryAgain, sign-in attempts, crawl, Shield, strangers and read-only.

Tests

  • Passed: retry-after, mcp-size, agents, the oauth, read-only, strangers, health and talk tests, ratelimit, openapi and crawl-budget. There was one browser failure, in oauth-connect-browser, where chromium would not start in this sandbox.
  • Retry-After test is looser than the issue asked. It checks every 429 and 503 site statically, but it drives only the shared responses, not each limit through a live server. The static check passes when a site sits near json( or apiError, so it would not catch a new site that builds its own Response near one of those.

Left out

  • Seconds are generic (60) at sites that don't know their wait: print, hooks, forks, preview build, following, reports and the proposal gate. They still carry the header and the field.
  • The notes 503 now sends 5 and the engine's no_mail sends 300; these are my guesses, not measured waits.
  • I did not touch src/apps/ (the per-app proxy and scaffold), since the issue scopes this to src/http.

Try it: with a token, hit any API route past its per-minute limit and read the Retry-After header and the retryAfter field in the JSON body.

Closes #10762

commented

Every 429 and 503 from src/http/ now carries an integer Retry-After. Where the body is JSON, it also names the same seconds as retryAfter. I ran tsc, scripts/gate.ts and a related batch of tests and all passed except the browser test described below. The full suite was left to the forge's check.

What changed

  • One floor in json() (src/http/respond.ts). Any 429 or 503 that goes through it gets the header and a retryAfter field. A site that already sets a wait keeps it; one that doesn't gets 60.
  • New helper tryAgain(code, status, message, seconds). It rounds up and never says 0. The edited sites in api.ts, server.ts, oauth.ts, api/user.ts, api/app_notes.ts and api/conversation_talk.ts use it with their real waits: rate limiters, sign-up, first admin, conversation tokens, code search, the daily copy limit, talk, OAuth register and revoke, no_mail, and the notes 503.
  • Non-JSON sites. Health and readiness 503s now send Retry-After: 5, and the shell-busy 429 sends 5. The read-only standby's JSON body now includes retryAfter (300). The crawl, Shield, stranger-door and stranger-code responses already had the header.
  • Docs. One line in docs/MCP.md and in the OpenAPI info.description. I added no tool.
  • Test (test/retry-after.test.ts). It scans src/http for 429 and 503 literals and fails if one has no wait-naming mechanism within 12 lines. It also checks the shared responses: json, tryAgain, sign-in attempts, crawl, Shield, strangers and read-only.

Tests

  • Passed: retry-after, mcp-size, agents, the oauth, read-only, strangers, health and talk tests, ratelimit, openapi and crawl-budget. There was one browser failure, in oauth-connect-browser, where chromium would not start in this sandbox.
  • Retry-After test is looser than the issue asked. It checks every 429 and 503 site statically, but it drives only the shared responses, not each limit through a live server. The static check passes when a site sits near json( or apiError, so it would not catch a new site that builds its own Response near one of those.

Left out

  • Seconds are generic (60) at sites that don't know their wait: print, hooks, forks, preview build, following, reports and the proposal gate. They still carry the header and the field.
  • The notes 503 now sends 5 and the engine's no_mail sends 300; these are my guesses, not measured waits.
  • I did not touch src/apps/ (the per-app proxy and scaffold), since the issue scopes this to src/http.

Try it: with a token, hit any API route past its per-minute limit and read the Retry-After header and the retryAfter field in the JSON body.

commented

Every 429 and 503 from the API and MCP now carries an integer Retry-After header and a retryAfter field in the body. A new test scans the code for 429 and 503 sites that don't.

The failing check was a stale docs/MCP.md. The tool descriptions changed with the Retry-After wording, but the generated tool table wasn't refreshed. I regenerated it with bun scripts/mcp-table.ts, which touched two lines. test/mcp-table.test.ts passes (8 tests). I didn't re-run the full suite here, since the forge runs it on push.

To try it: open /settings/tokens, make a token, then call any route fast enough to be told to slow down. The 429 names its seconds.

Changes requested — before the latest push

Header and body floor in json()/tryAgain is sound, but box 2 is not met and one fix is needed first: the test must actually drive the limits and must not be satisfiable by a nearby json(/apiError. Changes: (1) test/retry-after.test.ts: the scan passes any 429/503 literal with json(, apiError, retry-after, etc. within 12 lines, so a new site that builds its own Response beside one of those slips through. Make the check per-site (the helper call on the same statement, or a named exemption list), and drive the real limits (token per-minute limit, stranger limit, sign-up, oauth register/revoke, code search, /mcp) through the app and assert the integer header and equal body retryAfter. Currently only the shared helpers are called directly, with as never casts. (2) The json() default of 60 hides sites that never name a wait (print, hooks, reports, following, forks, data busy, preview build, proposal gate, rp.ts failure(...,429), user.ts too_soon/capped). Give each its real wait or an explicit tryAgain call, so the floor is a backstop not the mechanism. (3) conversation_talk stop: 503 uses fixed 60 and talk errors use fixed 60 regardless of cause; notes 503=5 and no_mail=300 are guesses; pick values from the real source of the delay. (4) Non-JSON 429/503 (healthz/readyz, shell busy) have the header but no body naming seconds; say so in docs or give them one. (5) docs/MCP.md: the new bullet is a single unwrapped long line; wrap it like its neighbours. Confirm the crew/explore table byte changes correspond to a real description change in this diff, otherwise drop them. (6) Confirm /mcp 429/503 are covered by a test; docs claim it.

commented

Every 429 and 503 in src/http now names its own wait, and test/retry-after.test.ts checks each site and drives the real limits. tsc and scripts/gate.ts pass. About 260 related tests pass, and one fails (below).

What changed

  • No more reliance on the default 60. print, hooks, reports, following, forks, data, preview build, proposal gate, rp.ts, user.ts, password change and the connect/OAuth limits each state their own wait; json()'s 60 stays only as a backstop.
  • Waits from the real source where one exists:

- hooks, reports, proposal gate, rp.ts and OAuth connect take the limiter's own retryAfter or window. - print takes the minute window, or the seconds to the next UTC day for the daily cap. - forks take the time until the oldest fork in the last day ages out. - the crew ceiling takes the seconds to the next UTC day. - the preview build takes BUILD_ASKS_WINDOW_MS. - email resend takes RESEND_AFTER_MS. - data busy takes QUERY_TIMEOUT_MS.

  • Conversation talk errors no longer share one fixed 60. rate_limited and busy are a minute by their own wording; queue_full (30), resting (300) and off (300) have no clock behind them, so those three are stated guesses in a named table.
  • Notes 503 (30) and no_mail (300) also have no clock behind them. They are named constants whose comments say they are guesses.
  • The test is per site. It walks the TypeScript syntax tree. A 429/503 literal passes only inside a tryAgain call, inside a call that spells its own retry-after, or on a short exemption list that gives each entry's reason. A second test shows a bare new Response(…, {status: 429}) next to a helper call is caught.
  • It drives the real limits through a running forge. Each is pushed to a 429 and asserts the integer header equals the body's retryAfter:

- the per-token limit - /mcp (covers your point 6) - the stranger per-address limit - stranger code search - sign-up - /oauth/register - /oauth/revoke - the read-only-forge 503

  • Docs. The docs/MCP.md bullet is wrapped and says plainly that /healthz, /readyz, a refused shell and the plain-text standby page carry the header only, with no body.
  • crew/explore table changes dropped. They came from other pulls' tool changes, so my diff had no reason for them. Merging origin/main brought in the same table, and my diff to that table is now empty.

Left alone

  • The bulk too_many 429 (more than 100 asks in one request) is a request-size cap, not a wait. It keeps the 60 s floor and sits on the exemption list with that reason.
  • The rp.ts failure page is on the exemption list because it sets the header on the next line.
  • The non-JSON answers have no body naming the seconds, as noted in the docs.

Test status

  • test/guest-contribute.test.ts fails on one test, "a person's own agent attaches by OAuth and walks the guest path through MCP and git", at git push (exit 128). The sandbox denied an outbound connection to ::1. I didn't confirm it also fails without my change, and the forge's check will show it.
  • scripts/fast.ts said the diff reaches 1820 files, so I ran the tests covering the touched areas by hand, not the full suite.
Approved

Earlier asks are met: each 429/503 site now names its own wait through tryAgain (or spells retry-after), the test walks the syntax tree per site and drives the real limits, including /mcp, and the docs line says which answers carry the header only. Security floor is unaffected, with no new input or secret. Non-blocking: the guessed waits (talk, notes, no_mail) are named constants, so tune them when real data exists. Not run here: the clone sits at the base commit and does not hold the head, so I read the diff only. The check is green.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs