Errorsretriesretry-afterbackofftransient failureserror contract

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.

Follow the failure

Frame the contract

API design starts with a consumer, a design question and a guarantee — never with a URL.

Design question
For every failure this API can produce, does the contract say whether, when and how the client should retry?
Consumers
Every retry wrapper ever pointed at your API: the SDK you ship, the partner's hand-rolled loop, the service mesh's automatic retry policy, the mobile app's sync queue — each of them will decide something, with or without your guidance.
The promise
A well-designed contract classifies every failure as retryable or not, states when and how to retry (delay, backoff, budget), and guarantees that following that guidance is safe — which quietly requires the idempotency story to exist.
RequirementConsumersResource ModelStyleContractValidationAuthorizationErrorsIdempotencyPaginationVersioningObservabilityEvolutionTrade-offs

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). 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.

Retry semantics by failure class — the table your docs should contain
FailureRetry?WhenWhat else the client must do
400/422 validationNeverFix the request; identical input fails identically
401 authenticationOnce, after re-authImmediatelyRefresh credentials first; do not loop
403 authorizationNoStop; permissions will not appear by repetition
409/412 conflictAfter re-readImmediatelyFetch current state, reconcile, maybe already done
429 rate limitedYesHonor Retry-After exactlyReduce send rate, not just delay one call
500 internalOnce or twiceBackoff + jitterThen surface; report with request_id
502/503/504YesBackoff + jitter, within a budgetRespect Retry-After if present; then degrade
Timeout / no responseOnly if idempotentBackoff + jitterOutcome 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.

A 503 that tells the retry loop everything it needs
Request
POST /v1/reports HTTP/1.1
Authorization: Bearer <token>
Idempotency-Key: 41af…
Content-Type: application/json

{ "type": "monthly", "month": "2026-07" }
Response
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-After converts guessing into a schedule — and is a promise you must keep, or clients learn to ignore it.
  • A per-code retryable flag 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.

  1. 1
    Provider → docs: documents errors but says nothing about retrying; each consumer invents a policy.
  2. 2
    Consumer A → API: retries everything including 422s; consumer B retries nothing and shows users hard errors for blips.
  3. 3
    Provider → incident: a 10-second deploy blip hits; thousands of naive loops retry immediately and in sync.
  4. 4
    Retry storm → API: the recovering service is hit with multiples of normal load and goes back down; the blip becomes an outage.
  5. 5
    Provider → postmortem: adds aggressive rate limits overnight, which now also throttle the well-behaved clients the contract never taught.
What breaks
  • 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.

Design the contract
  • • 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.
Observe in production
  • • 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.
Evolve without breaking
  • • 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.
What it costs
  • • 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.

Misconceptions

Claim
“Retry policy is the client's business — the API just reports errors.”
Reality
The composed behavior of every client's retry loop lands on your infrastructure during your worst moments. Retryability guidance is self-defense; the contract is the only channel you have to shape the herd.
Claim
“5xx means retryable, 4xx means not — the status class is the whole answer.”
Reality
It is the right first approximation and wrong at both edges: 429 is a 4xx that explicitly schedules a retry, 501 is a 5xx that never benefits from one, 409 depends on which conflict, and a 500 from a non-idempotent POST is only *safely* retryable with an idempotency key. The class is the fallback; codes and flags carry the truth.
Claim
“A timeout means the request failed, so retrying is safe.”
Reality
A timeout means you stopped waiting — the server may have completed the work after you left. Retrying a timed-out non-idempotent request is how double charges happen; safety comes from the idempotency contract, never from the timeout itself.

Apply it