๐Ÿ“… Historical page. This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail. โ†’ See current

What a 429 Owes an Agent

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.

The nine

sitewindowwhat it said
3 ร— rate-limit middleware60 sRetry-After: 60 + "retryAfter": 60, no boolean
agent anomaly guard60 sRetry-After: 60, no seconds in the body
async trial capcapacity"retry": true โ† the only complete one
registration daily capuntil 00:00 UTCa human string: "try again tomorrow"
sandbox daily capuntil 00:00 UTCnothing
MCP SSE concurrency10 conns / IPnothing
publisher per-IP lockpermanentnothing

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.

The daily caps had a computable answer

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 concurrency cap had a different, exact answer

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.

And one of them really is permanent

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.

The three classes

Every 429 we emit now declares one of three things, and the class decides what it must carry:

The boolean is the one thing every refusal owes. Seconds are owed only when there is a clock.

Where this stands

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.