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
Check · Review · Land · Live done — Landed and live in production.
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
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 aretryAfterfield. 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 inapi.ts,server.ts,oauth.ts,api/user.ts,api/app_notes.tsandapi/conversation_talk.tsuse 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 includesretryAfter(300). The crawl, Shield, stranger-door and stranger-code responses already had the header. - Docs. One line in
docs/MCP.mdand in the OpenAPIinfo.description. I added no tool. - Test (
test/retry-after.test.ts). It scanssrc/httpfor 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,openapiandcrawl-budget. There was one browser failure, inoauth-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(orapiError, so it would not catch a new site that builds its ownResponsenear 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_mailsends 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 tosrc/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.
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.
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.
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_limitedandbusyare a minute by their own wording;queue_full(30),resting(300) andoff(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
tryAgaincall, 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 barenew 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.mdbullet 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/mainbrought in the same table, and my diff to that table is now empty.
Left alone
- The bulk
too_many429 (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.tsfails on one test, "a person's own agent attaches by OAuth and walks the guest path through MCP and git", atgit 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.tssaid the diff reaches 1820 files, so I ran the tests covering the touched areas by hand, not the full suite.
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.
Every 429 and 503 from
src/http/now carries an integerRetry-After. Where the body is JSON, it also names the same seconds asretryAfter. I rantsc,scripts/gate.tsand 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
json()(src/http/respond.ts). Any 429 or 503 that goes through it gets the header and aretryAfterfield. A site that already sets a wait keeps it; one that doesn't gets 60.tryAgain(code, status, message, seconds). It rounds up and never says 0. The edited sites inapi.ts,server.ts,oauth.ts,api/user.ts,api/app_notes.tsandapi/conversation_talk.tsuse 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.Retry-After: 5, and the shell-busy 429 sends 5. The read-only standby's JSON body now includesretryAfter(300). The crawl, Shield, stranger-door and stranger-code responses already had the header.docs/MCP.mdand in the OpenAPIinfo.description. I added no tool.test/retry-after.test.ts). It scanssrc/httpfor 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
retry-after,mcp-size,agents, the oauth, read-only, strangers, health and talk tests,ratelimit,openapiandcrawl-budget. There was one browser failure, inoauth-connect-browser, where chromium would not start in this sandbox.json(orapiError, so it would not catch a new site that builds its ownResponsenear one of those.Left out
no_mailsends 300; these are my guesses, not measured waits.src/apps/(the per-app proxy and scaffold), since the issue scopes this tosrc/http.Try it: with a token, hit any API route past its per-minute limit and read the
Retry-Afterheader and theretryAfterfield in the JSON body.Closes #10762