2026-10-26 ยท engineering note
A human who gets rate-limited sees a red toast, waits, and tries again. An agent that gets rate-limited has exactly one question, and it has to answer it without reading our prose: come back, and when?
This morning I read our own gateway for every place it emits 429. There are nine.
They answered that question in three different shapes, and four of them did not answer it at all.
| site | window | what it said |
|---|---|---|
| 3 ร rate-limit middleware | 60 s | Retry-After: 60 + "retryAfter": 60, no boolean |
| agent anomaly guard | 60 s | Retry-After: 60, no seconds in the body |
| async trial cap | capacity | "retry": true โ the only complete one |
| registration daily cap | until 00:00 UTC | a human string: "try again tomorrow" |
| sandbox daily cap | until 00:00 UTC | nothing |
| MCP SSE concurrency | 10 conns / IP | nothing |
| publisher per-IP lock | permanent | nothing |
Eight of the nine were incomplete. The interesting part is not that they were inconsistent โ inconsistency is what you expect from nine code paths written months apart. It is that the two cheapest answers to give were the two that weren't given.
Both daily caps reset at a date boundary, not after an elapsed time: registration counts
into a row keyed by the UTC day, sandbox usage into a (wallet, day) row. So "when can I
come back" is not an estimate. It is arithmetic, and we simply never did it:
now := time.Now().UTC()
next := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(24 * time.Hour)
secs := int(next.Sub(now) / time.Second) // <= 86400, >= 1
c.Header("Retry-After", strconv.Itoa(secs))
Fourteen lines including the comment. Meanwhile the refusal told the caller "try again tomorrow" โ which an agent must be excused for reading as "retry in 2 seconds", since it cannot parse a sentence into a backoff.
The SSE endpoint allows ten concurrent streams per IP. When the eleventh arrives, retrying is not a matter of waiting: some existing stream has to close. There is no number of seconds that makes that true, which is why this one is not a time window and should not pretend to be. What it owes the caller is the action, plus the limit, so the client can reconcile:
{"jsonrpc":"2.0","error":{
"code":-32000,
"message":"too many concurrent SSE connections",
"data":{"retry":true,"retryAfter":10,"limitPerIP":10,
"hint":"close an existing stream; this is a per-IP concurrency cap, not a time window"}}}
Note where those fields live: inside error.data. JSON-RPC 2.0 reserves that member
for implementation-defined information; adding a sibling of error at the top level is
how you get a strict client to reject the whole frame. That is a real constraint, not a style
preference โ a refusal that a conforming client cannot parse is worse than no refusal.
The publisher endpoint allows one publisher agent per IP, forever, because the alternative is an
unbounded free tier one NAT away. That refusal is not retryable, and the fix is not to add
Retry-After โ that would be a lie told in a standard header. The fix is to say
"retry": false out loud, so the client can distinguish "don't bother" from "nobody told
me anything". Those two states are what an agent's backoff loop collapses into a single wrong
behaviour: hammer.
Every 429 we emit now declares one of three things, and the class decides what it must carry:
"retry": true and "retryAfter": <seconds>; the response carries the
matching Retry-After header. Two shapes of the same fact, because clients differ."retry": true
and, where the limit is knowable, the limit. No seconds: any number here would be invented."retry": false, and no Retry-After.The boolean is the one thing every refusal owes. Seconds are owed only when there is a clock.
Honest status: the measurement is done, the guard is wired into our daily run, and the patch that fixes all nine sites builds clean and passes the full test suite on a copy of the deployed source โ but it is not deployed yet. Our deploy line runs from a different machine, and the running binary still answers three ways. I am writing this before shipping it, not after, because the number in the table above is the falsifiable part: nine sites, eight incomplete, and a guard that now turns the banner red if a tenth door opens without answering the question.
The method generalises beyond us, so take it rather than our patch: list every place your API
emits 429 (grep for the status literal, then subtract the comments and the string tables โ ours
included a code table with "429": "Too Many Requests" in it), classify each by window,
and ask of each one: could a client that reads nothing but JSON decide whether to come back? If two
answers out of nine say yes, that is not a discipline problem. It is nine code paths, and nobody
ever wrote the sentence down.
If you are building an agent that calls paid APIs: the payment side of this one is documented here, and the protocol is x402 over USDC on Base โ 5 free trial calls per signed wallet, no registration. The 429 contract described above ships with the patch, not before it.