Retryability: Telling Clients What To Do Next
Every error answers a question the client is definitely asking: do I try again? A contract that states retryability explicitly — status semantics, Retry-After, a retryable flag — replaces a thousand guessed retry loops with one correct one.
Frame the contract
API design starts with a consumer, a design question and a guarantee — never with a URL.
The client will retry something — the contract decides what
Retry behavior is not optional client politeness; it is forced by physics. Networks time out, connections reset, load balancers shed. Any client that wants to be reliable wraps calls in a retry loop — and if your contract is silent, the loop's policy is invented from folklore: "retry 5xx three times", "retry everything idempotent", "retry everything, it is probably fine". Multiply an invented policy by every consumer and you get the two standard disasters: permanent failures hammered thirty times each, and a synchronized retry storm that turns a 10-second blip into a 10-minute outage.
The contract's job is to replace guessing with three explicit answers per failure: can retrying help (card_declined: no, ever; 503: yes), when (Retry-After: 30, or "exponential backoff from 1s"), and is it safe — the answer that does not live in the error at all, but in the operation's idempotency contract (see Idempotency: Surviving the Retry). A 504 on POST /payments is retryable by timing and catastrophic by safety unless Idempotency Keys: The Mechanism make the retry a no-op. Retryability guidance without an idempotency story is an invitation to duplicate side effects.
The hardest case is the one with no response: a timeout. The client cannot know whether the request was processed — the answer is genuinely unknowable from its side (see Retries and Timeouts as Contract Guidance). The contract cannot eliminate the ambiguity, but it decides whether the ambiguity is *safe*: with idempotency keys, "retry on timeout" is correct advice; without them, the honest documentation is "on timeout, reconcile by reading state before retrying" — advice almost no client follows, which is the argument for the keys.
| Failure | Retry? | When | What else the client must do |
|---|---|---|---|
| 400/422 validation | Never | — | Fix the request; identical input fails identically |
| 401 authentication | Once, after re-auth | Immediately | Refresh credentials first; do not loop |
| 403 authorization | No | — | Stop; permissions will not appear by repetition |
| 409/412 conflict | After re-read | Immediately | Fetch current state, reconcile, maybe already done |
| 429 rate limited | Yes | Honor Retry-After exactly | Reduce send rate, not just delay one call |
| 500 internal | Once or twice | Backoff + jitter | Then surface; report with request_id |
| 502/503/504 | Yes | Backoff + jitter, within a budget | Respect Retry-After if present; then degrade |
| Timeout / no response | Only if idempotent | Backoff + jitter | Outcome unknown — needs idempotency key or a reconciling read |
Retry-After and the retryable flag: guidance in-band
Two mechanisms carry the guidance in the response itself, where the retry loop can see it. Retry-After (seconds, or an HTTP date) on 429 and 503 is the server telling the client something the client cannot compute: when capacity or quota will exist again. It converts a guessing game into a schedule — and it is a promise: a server that says Retry-After: 30 and still rejects at 31 seconds teaches clients to ignore the header, which un-designs the mechanism for everyone.
A body-level retryable: true|false flag (or equivalently, documented retryability per error code) covers what status classes cannot: two 409s may differ — stale_version is retryable after a re-read, duplicate_email is not retryable at all. The flag lets your SDK implement one loop — if (!err.retryable) throw — that is correct for every current and future error code, which matters because your SDK's loop is the retry policy most consumers will actually run (see SDK Design: The Contract's User Interface).
Backoff parameters belong in the docs rather than per-response: initial delay, multiplier, jitter, and a budget (max attempts or total time). Jitter is not an optimization but a herd-safety requirement — a thousand clients that all saw the same failure at the same moment and all retry at exactly 1s, 2s, 4s re-synchronize into waves that keep a recovering service down. Documented jitter is the contract's contribution to your own survivability.
POST /v1/reports HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 41af…
Content-Type: application/json
{ "type": "monthly", "month": "2026-07" }HTTP/1.1 503 Service Unavailable
Retry-After: 12
Request-Id: req_01JA…
Content-Type: application/json
{
"error": {
"code": "upstream_unavailable",
"message": "The reporting backend is briefly unavailable.",
"retryable": true,
"request_id": "req_01JA…"
}
}Design for the worst client, and for the storm
You do not control the retry loops pointed at you; you shape them. Assume the worst client exists — no backoff, no jitter, no budget, retries 4xx — because at scale it does. Server-side rate limits are the backstop that makes the worst client survivable (see The Rate-Limit Contract); explicit retryability guidance is what keeps the *good* clients from accidentally behaving like the worst one during an incident. Both layers exist because the other one fails sometimes.
The storm dynamics are worth internalizing with numbers. Suppose 1,000 clients each send 1 rps and you blip for 10 seconds. Naive immediate-retry clients turn 10,000 failed requests into 10,000 instant retries stacked on top of live traffic — a 2x spike aimed at a service that just proved it cannot handle 1x. With exponential backoff, full jitter and a 3-attempt budget, the same failure spreads roughly the same retries over ~30 seconds as a gentle slope. Same clients, same outage, opposite recovery curve — the difference is only what the contract taught them to do.
Retry budgets are the piece most contracts forget: unbounded retrying converts one downstream outage into a memory-and-connection leak in every consumer, and a queue of stale retries that replays the load spike after recovery. "At most 3 attempts or 30 seconds, then surface the failure" is contract guidance, and it pairs with An Error Taxonomy Clients Can Branch On: budgets apply to dependency-class failures, never to validation-class ones, where the correct budget is zero.
- Document per-code retryability, backoff (with jitter) and a budget — the loop you describe is the loop your SDK ships.
- Honor your own
Retry-After: a header clients learn to distrust is worse than no header. - Rate limits are the backstop for clients that ignore all of it; both layers are needed.
- 10s outage + naive retries = amplified spike at recovery; the same outage + jittered backoff = a slope. The contract picks.
Key points
- Clients will retry regardless; the contract's choice is whether their policy is designed or folklore.
- Every failure needs three answers: can retrying help, when, and is it safe — and "safe" is the idempotency contract's job, not the error's.
- Timeouts are the genuinely ambiguous case: the outcome is unknowable client-side, so retry-on-timeout is only honest advice where idempotency keys exist.
Retry-Afterconverts guessing into a schedule — and is a promise you must keep, or clients learn to ignore it.- A per-code
retryableflag lets one SDK loop be correct for every current and future error. - Jitter and budgets are herd-safety: they are the difference between a recovery slope and a self-inflicted retry storm.
Follow the failure
How the contract fails or gets misused, hop by hop — and what it costs when it completes.
- 1Provider → docs: documents errors but says nothing about retrying; each consumer invents a policy.
- 2Consumer A → API: retries everything including 422s; consumer B retries nothing and shows users hard errors for blips.
- 3Provider → incident: a 10-second deploy blip hits; thousands of naive loops retry immediately and in sync.
- 4Retry storm → API: the recovering service is hit with multiples of normal load and goes back down; the blip becomes an outage.
- 5Provider → postmortem: adds aggressive rate limits overnight, which now also throttle the well-behaved clients the contract never taught.
- Recovery inversion: the API is kept down by its own clients' reaction to it going down — load is highest exactly when capacity is lowest.
- Duplicate side effects: retry guidance without idempotency turns every timeout into a possible double charge, double email, double job.
- Uneven consumer experience: identical blips surface as invisible (well-tuned client) or as user-facing failures (naive client), and support cannot explain the difference.
Design, observe, evolve
A contract decision is incomplete until you know how you would notice it failing and how it changes later.
- • Publish the retryability table — per status and per error code — as a contract clause, and encode it in a body-level `retryable` flag your SDKs obey.
- • Send `Retry-After` on 429 and 503, computed from real capacity or quota windows, and honor it server-side.
- • Make retry-on-timeout safe before recommending it: idempotency keys on every non-idempotent operation clients are told to retry (see [[idempotency-keys]]).
- • Document backoff with jitter and an explicit budget, and back it all with server-side rate limiting for the clients that read nothing.
- • Attribute retries in traffic: an `Idempotency-Key` or request-id seen twice, or an SDK retry-attempt header, lets you graph retry rate — the earliest storm indicator you can have.
- • Watch the ratio of retried-then-succeeded to retried-then-failed per error code: a code that never succeeds on retry but attracts retries is misdocumented or misclassified.
- • During incidents, graph offered load vs served load; a widening gap after the trigger clears is the signature of a retry storm forming.
- • Marking a previously non-retryable code as retryable is additive; the reverse strands deployed retry loops and needs a deprecation window and telemetry on who retries it.
- • Tightening backoff guidance (longer delays, smaller budgets) rolls out through SDK releases — another reason the SDK, not the doc page, should own the loop.
- • New transient failure modes should reuse the existing `retryable`/`Retry-After` vocabulary rather than inventing per-feature retry advice.
- • Honest `Retry-After` requires the server to actually know when capacity returns — real work in the rate limiter and during incidents, and a credibility cost when you guess wrong.
- • A `retryable` flag is a strong promise made at error-design time; marking something retryable and later discovering a side effect is a contract bug with duplicate-effect consequences.
- • Teaching clients to retry increases baseline load and masks marginal failures — some percentage of your errors get silently absorbed, which is the point, but it delays noticing chronic ones.