Filter concepts by levelShowing all levels.

System Design · Section 31

Retry Strategy

Level
intermediate
Read
18 min
Concepts
5

Retries are meant to paper over brief, one-off failures, but uncontrolled retries — no cap, no backoff, every layer of a call chain retrying independently — add load onto a struggling service at exactly the moment it can least handle it, turning a small blip into a sustained outage. Timeouts, exponential backoff and jitter work together to make retries safe: a timeout bounds how long one attempt waits, backoff grows the wait between attempts, and jitter randomizes that wait so many clients don't retry in a synchronized burst. Not every error is worth retrying — transient errors (503, 429, timeouts) have a real chance of succeeding later, while permanent errors (400, 401/403, 404) will fail identically every time. A maximum attempt count bounds the retry loop, and a dead-letter queue makes whatever exhausts its retries visible instead of silently lost. Finally, a timeout cannot prove whether a request actually succeeded — blindly retrying a non-idempotent operation risks duplicating its side effect, which is why retry logic must respect idempotency rather than assume every request is safe to repeat.

System Design overview

What is true here

  1. Uncontrolled retries can amplify a brief outage into a sustained one, especially when compounding across multiple layers of a call chain.
  2. Timeouts bound one attempt; exponential backoff grows the wait between attempts; jitter randomizes that wait to avoid synchronized retry bursts.
  3. Classify errors as transient (worth retrying) or permanent (never will succeed) before deciding to retry at all.
  4. Cap the maximum number of attempts, and dead-letter whatever exhausts its retries instead of dropping it silently.
  5. A timeout is ambiguous about whether the request actually succeeded — only blindly retry operations that are genuinely idempotent.

What you will be able to do

  • Explain why uncontrolled retries can make an outage worse instead of better
  • Apply exponential backoff with jitter, and explain what each of the two techniques contributes separately
  • Classify a given error as transient or permanent and decide whether it is worth retrying
  • Design a bounded retry policy with a dead-letter queue for whatever exhausts its attempts
  • Recognize when an operation is not safe to blindly retry and needs an idempotency guard first

The danger, and the controls that contain it

Why retries can backfire when uncontrolled, and the timeout/backoff/jitter combination that keeps them safe.

Retries can amplify outages if uncontrolled

coreintermediate

A retry is meant to paper over a brief, one-off failure — but when a downstream service is already struggling under load, every client retrying its failed request adds MORE load onto the exact service that is failing, at the exact moment it can least handle it. Uncontrolled retries — no cap on attempts, no backoff, every client retrying instantly and in lockstep — can turn a small, recoverable blip into a sustained, self-sustaining outage, because the retry traffic itself becomes the dominant source of load.

Think of it as

Picture a crowded doorway during a fire drill: a few people are stuck because the door briefly jams. If everyone immediately shoves the door again the instant it doesn't open, the crowd pressing on the door only grows — new people arrive at the back of the line while the ones in front keep retrying, and the door has even less chance of easing open than if people paused and thinned out. An uncontrolled retry storm is that same crowd, except the "door" is a struggling downstream service, and each shove is a fresh request adding to the load that's already causing the jam.

text
retry storm = many clients x retrying together x
               against an already-overloaded service
             = load INCREASES exactly when capacity
               is already the constraint

What we're doing: Show how a single downstream blip fans out into a much larger retry storm across a call chain.

retry-amplification.txttext
Chain: Frontend -> Service A -> Service B -> Database

Database has a brief 2-second slowdown.

Each layer retries up to 3 times, independently,
with no shared budget or awareness of the other layers:

  Service B's call to the database:      up to 3x calls
  Service A's call to Service B:         up to 3x calls,
    each of which can itself retry 3x -> up to 9x calls
  Frontend's call to Service A:          up to 3x calls,
    each of which can retry 9x -> up to 27x calls

One 2-second blip at the database can turn into
up to 27x the normal request volume arriving at the
database, sustained well past the original 2 seconds.
10
Service A retrying its call to B multiplies B's already-retried calls to the database — 3 retries at A on top of 3 retries at B compounds to up to 9x.
12
The frontend layer compounds the same way again, reaching up to 27x the original request volume from what was a single brief database slowdown.

Why this works: Retry amplification is rarely visible at a single layer in isolation — it is the multiplicative compounding across an entire call chain that turns a two-second blip into a sustained overload, which is why retry budgets need to be reasoned about system-wide, not layer by layer.

Adding retries at every layer of a call chain independently

Wrong

text
"Each service retries its own downstream
calls up to 3 times — that seems safe enough
per service."

Better

text
"Only the outermost layer (or a single
designated layer) retries; inner layers fail
fast and propagate the error upward, so retry
attempts don't multiply across the chain."

What you see: A brief, minor downstream slowdown is followed by a much longer, much larger spike in request volume against that same downstream service — the load spike outlives and outsizes the original incident.

Why: Retrying independently at every layer of a call chain multiplies attempt counts layer over layer; the fix is to concentrate retry logic at one layer (typically the outermost caller, or a dedicated client library with a shared retry budget) rather than letting every layer add its own retries on top of the layers below it.

A 2-second blip amplifies to 27x, retried at every layer
up to 3xup to 9xup to 27x

Frontend

retries ×3

Service A

retries ×3 → up to 9x

Service B

retries ×3 → up to 27x

Database

2s blip, now overloaded

  • Frontend — retries ×3
    • leads to Service A (up to 3x)
  • Service A — retries ×3 → up to 9x
    • leads to Service B (up to 9x)
  • Service B — retries ×3 → up to 27x
    • on error, leads to Database (up to 27x)
  • Database — 2s blip, now overloaded

Retry policy choices and their effect on downstream load

Retry policy choices and their effect on downstream load
PolicyEffect on a struggling downstream service
No retriesFailures are visible immediately but no amplification — safest for downstream, worst for client-perceived reliability on brief blips
Immediate retry, no capInstantly re-adds full load; if the cause was overload, this piles on more of exactly what caused the failure
Retry with exponential backoffLoad from each individual client fades over time, giving the downstream service room to recover
Retry with backoff + jitterSame fading load, but spread out in time across clients instead of arriving in synchronized bursts
Retry with backoff + jitter + max attemptsBounded worst case — a client eventually gives up and surfaces the failure instead of retrying forever

Remember: Retries fix brief, one-off failures — but uncontrolled retries (no cap, no backoff, every layer retrying independently) add load onto a service at exactly the moment it is least able to handle more, turning a small blip into a sustained, self-inflicted outage.

See also: backoff and jitter · max attempts and dead lettering

Timeouts, exponential backoff and jitter

coreintermediate

Three techniques work together to make retries safe. A timeout bounds how long a client waits before deciding a request has failed, so a single slow call cannot hang a caller indefinitely. Exponential backoff waits progressively longer between each retry attempt (e.g. 1s, 2s, 4s, 8s) instead of retrying instantly, giving a struggling downstream service increasing room to recover. Jitter adds randomness to that wait time so that many clients retrying after the same failure do not all retry at the exact same moment — without jitter, backoff alone can still produce synchronized bursts of retries arriving together.

Think of it as

Think of a crowded restaurant that just reopened after briefly closing its kitchen. Exponential backoff is like each waiting party agreeing to check back at doubling intervals — 1 minute, then 2, then 4 — instead of everyone crowding the host stand every ten seconds. Jitter is the host telling each party a slightly different check-back time ("you at 4 minutes, you at 4-and-a-half, you at 3-and-three-quarters") so the crowd doesn't all arrive back at the stand in the same synchronized wave, which would recreate the exact crush the waiting was meant to avoid.

text
# exponential backoff with full jitter (common pattern)
backoff = min(cap, base * 2 ** attempt)
wait    = random_between(0, backoff)

What we're doing: Compare backoff without jitter against backoff with full jitter across a handful of retrying clients.

backoff-jitter-comparison.txttext
base = 1s, cap = 30s, all 4 clients fail at t=0

Exponential backoff, no jitter (wait = min(cap, base*2^attempt)):
  attempt 1: all 4 clients wait exactly 2s -> retry together at t=2s
  attempt 2: all 4 clients wait exactly 4s -> retry together at t=6s
  -- every retry arrives as one synchronized burst

Exponential backoff, full jitter (wait = random(0, min(cap, base*2^attempt))):
  attempt 1: clients wait 0.3s, 1.7s, 0.9s, 1.4s -> spread across ~2s
  attempt 2: clients wait 2.1s, 3.6s, 0.5s, 3.9s -> spread across ~4s
  -- retries land throughout the window instead of at one instant
4
Without jitter, every client computes the identical wait from the identical backoff formula, so all 4 retries land at the same instant — a synchronized burst.
9
Full jitter randomizes each client's wait within the same window, so the same 4 retries spread out over roughly two seconds instead of arriving together.

Why this works: This is the concrete mechanism by which jitter prevents retry storms — backoff alone controls how much total retry traffic there is over time, but only jitter controls whether that traffic clusters into synchronized spikes or spreads out smoothly.

Using exponential backoff without jitter and assuming that alone prevents retry storms

Wrong

text
wait = min(cap, base * 2 ** attempt)
# "we back off exponentially, so we're safe
#  from retry storms"

Better

text
backoff = min(cap, base * 2 ** attempt)
wait = random_between(0, backoff)
# jitter is what prevents clients that failed
# together from retrying together

What you see: A downstream service sees load arrive in sharp, periodic spikes that line up with the backoff schedule (e.g. a spike every 2s, then every 4s, then every 8s) rather than smoothly declining traffic — a signature of synchronized, un-jittered backoff.

Why: Exponential backoff by itself only changes the wait duration, not whether many clients that failed at the same time compute the same wait duration — since they typically do, un-jittered backoff still produces synchronized retry bursts, just spaced further apart over time instead of eliminated.

4 clients, all failed at t=0 — retry timing

No jitter

  • +All 4 clients wait exactly 2s
  • +All 4 retry together at t=2s
  • +Every retry arrives as one synchronized burst

Full jitter

  • Waits randomized: 0.3s, 1.7s, 0.9s, 1.4s
  • Retries spread across the ~2s window
  • No synchronized spike, smooth traffic instead
  • No jitter
    • All 4 clients wait exactly 2s
    • All 4 retry together at t=2s
    • Every retry arrives as one synchronized burst
  • Full jitter
    • Waits randomized: 0.3s, 1.7s, 0.9s, 1.4s
    • Retries spread across the ~2s window
    • No synchronized spike, smooth traffic instead

Backoff strategies, in order of increasing protection against synchronized retries

Backoff strategies, in order of increasing protection against synchronized retries
StrategyWait calculationWeakness
No backoffFixed, immediate retry every timeEvery client retries at the same rate — easiest to cause a retry storm
Exponential backoff, no jitterwait = base * 2^attempt, cappedClients that failed at the same moment retry at the same later moment too — still synchronized
Exponential backoff, full jitterwait = random(0, base * 2^attempt)Retry timing is spread across the whole window — most effective at avoiding synchronized bursts
Exponential backoff, equal jitterwait = (base * 2^attempt / 2) + random(0, base * 2^attempt / 2)Still spreads retries, with a higher guaranteed minimum wait than full jitter

Remember: Timeout bounds how long one attempt waits; exponential backoff grows the wait between attempts (capped, so it doesn't grow forever); jitter randomizes that wait so many clients retrying after the same failure don't retry in a synchronized burst. All three work together — none of them alone is a complete retry strategy.

See also: retry amplification · transient vs permanent errors

Advertisement

Deciding what to retry, and when to stop

Classifying errors before retrying, bounding the retry loop, and respecting idempotency so a retry never duplicates a side effect.

Differentiate transient errors from permanent errors

standardintermediate

Not every failure is worth retrying. A transient error is one that has a real chance of succeeding on a later attempt — a brief network blip, a momentary connection-pool exhaustion, a downstream service that is temporarily overloaded (often signaled by a 503 or 429 status). A permanent error will fail again in exactly the same way no matter how many times it is retried — malformed input, a missing resource (404), an authorization failure (401/403), or a request that violates a business rule. Retrying a permanent error wastes attempts, adds latency, and adds pointless load to the downstream service for zero chance of success.

Think of it as

It is the difference between a stuck elevator and a locked door with the wrong key. A stuck elevator might free itself if you press the button again in a minute — that is worth a retry. A locked door will never open no matter how many times you turn the same wrong key; trying it again ten more times does not change the outcome, it just wastes time standing at the door. Reading which situation you are in before deciding whether to retry is the whole point of classifying the error first.

text
on error:
    if is_transient(error):
        retry with backoff + jitter, up to max attempts
    else:
        fail fast — surface the error, do not retry

What we're doing: Show a retry decision function that classifies an error before deciding whether to retry.

error-classification.txttext
def should_retry(status_code):
    transient = {429, 502, 503, 504}
    permanent = {400, 401, 403, 404, 422}

    if status_code in transient:
        return True
    if status_code in permanent:
        return False
    # unknown/unmapped codes: treat conservatively —
    # do not retry by default, since retrying an
    # unrecognized permanent failure has no upside
    return False
5
Status codes explicitly known to be transient are the only ones that trigger a retry — everything else defaults to not retrying.
11
Unrecognized codes fall through to "do not retry" rather than "retry by default" — the safer default, since retrying an unknown permanent failure only adds load for no chance of success.

Why this works: Building the classification as an explicit allowlist of transient codes (rather than a denylist of permanent ones) means a new, unanticipated error code defaults to the safe behavior of not retrying, instead of silently retrying something that might be permanent.

Transient vs. permanent errors

Transient — retry

  • +429 Too Many Requests, 503, 504
  • +A temporary condition that can resolve itself
  • +Honor Retry-After if the response provides it

Permanent — fail fast

  • 400 Bad Request, 401/403, 404
  • The identical request fails identically every time
  • Retrying only adds latency and load, zero upside
  • Transient — retry
    • 429 Too Many Requests, 503, 504
    • A temporary condition that can resolve itself
    • Honor Retry-After if the response provides it
  • Permanent — fail fast
    • 400 Bad Request, 401/403, 404
    • The identical request fails identically every time
    • Retrying only adds latency and load, zero upside

Common HTTP status codes, classified for retry purposes

Common HTTP status codes, classified for retry purposes
Status codeMeaningRetry?
429 Too Many RequestsRate limitedYes — ideally honoring a Retry-After header
503 Service UnavailableDownstream temporarily overloaded or downYes — with backoff
504 Gateway TimeoutUpstream did not respond in timeYes — with backoff, cautiously (see non-idempotent risk)
500 Internal Server ErrorUnhandled server-side faultSometimes — depends on whether the fault is transient; often treated as retryable with caution
400 Bad RequestMalformed requestNo — the same request will fail identically
401 Unauthorized / 403 ForbiddenAuth/permission failureNo — retrying without fixing credentials cannot succeed
404 Not FoundResource does not existNo — the resource will not appear from retrying

Remember: Transient errors (503, 429, timeouts) have a real chance of succeeding on retry; permanent errors (400, 401/403, 404) will fail identically every time. Classify before retrying — retrying a permanent error only adds latency and load for zero chance of success.

See also: retry amplification · backoff and jitter · non idempotent retry danger

Set maximum attempts and dead-letter failed messages

coreintermediate

A retry policy needs a stopping point. Without a maximum attempt count, a message or request that will never succeed (because the underlying cause is not actually transient, despite looking like it) gets retried forever, consuming resources and hiding the fact that it is permanently stuck. A dead-letter queue (DLQ) is where a message goes after it exhausts its retry attempts without succeeding — instead of being silently dropped or retried forever, it is set aside for inspection, alerting, or manual reprocessing, so the failure is visible and recoverable rather than invisible and lost.

Think of it as

Think of a piece of mail that keeps bouncing back as undeliverable. A postal service does not attempt delivery forever — after a set number of attempts, the letter goes to a dead-letter office instead of being redelivered indefinitely or thrown away. Someone can go open that office, see exactly which letters failed and why, and decide whether to fix the address and resend or discard it. A dead-letter queue for failed messages is that same office: a bounded number of delivery attempts, then a visible, inspectable holding place instead of an infinite retry loop or a silent loss.

text
on message failure:
    attempt += 1
    if attempt >= max_attempts:
        send to dead_letter_queue
        alert()
    else:
        retry with backoff + jitter

What we're doing: Show a message consumer that dead-letters a message after exhausting its retry budget.

dead-letter-consumer.txttext
MAX_ATTEMPTS = 5

def process(message):
    try:
        handle(message)
    except TransientError:
        message.attempt_count += 1
        if message.attempt_count >= MAX_ATTEMPTS:
            dead_letter_queue.send(message)
            alert("message exhausted retries",
                  message_id=message.id)
        else:
            wait = backoff_with_jitter(message.attempt_count)
            requeue(message, delay=wait)
    except PermanentError:
        dead_letter_queue.send(message)
        alert("permanent failure", message_id=message.id)
8
The attempt count is checked against the max BEFORE deciding to requeue — this is what actually bounds the retry loop instead of retrying forever.
9
Exhausting retries sends the message to the dead-letter queue and raises an alert — the failure becomes visible instead of silently vanishing.
16
A permanent error skips the retry loop entirely and goes straight to the dead-letter queue — retrying it would only waste the attempt budget on something that can never succeed.

Why this works: This is the concrete shape of the two-part rule: cap retries so a stuck message cannot loop forever, and route what exhausts (or is immediately un-retryable) somewhere visible so the failure gets investigated instead of disappearing.

Setting a max attempt count but not routing exhausted messages anywhere

Wrong

text
if attempt >= max_attempts:
    log.error("giving up on message")
    # message is discarded here — nothing
    # captures or replays it

Better

text
if attempt >= max_attempts:
    dead_letter_queue.send(message)
    alert("message exhausted retries",
          message_id=message.id)

What you see: Data quietly goes missing — a downstream system never receives an event or order that the source system logs as having "attempted and given up on," with no queue or store anyone can check to recover it.

Why: A max attempt count alone only stops the retry loop — it says nothing about what happens to the message once retries are exhausted; without a dead-letter queue, "giving up" is indistinguishable from silent data loss, since nothing preserves the failed message for investigation or replay.

Bounded retries, then a visible dead-letter queue
checkunder maxexhausted

Message fails

attempt += 1

attempt < max?

backoff + jitter

Retry attempt

requeued

Dead-letter queue

inspection + alert

  • Message fails — attempt += 1
    • leads to attempt < max? (check)
  • attempt < max? — backoff + jitter
    • leads to Retry attempt (under max)
    • on error, leads to Dead-letter queue (exhausted)
  • Retry attempt — requeued
  • Dead-letter queue — inspection + alert

What happens to a message that keeps failing, by policy

What happens to a message that keeps failing, by policy
PolicyOutcome for a message that never succeeds
No max attemptsRetried forever — consumes resources indefinitely, and the queue may stall behind it (a poison message)
Max attempts, no DLQRetries stop, but the message is silently dropped — the failure is invisible and unrecoverable
Max attempts + DLQRetries stop, the message moves to a separate queue for inspection, alerting and deliberate reprocessing

Remember: Cap retry attempts so a message that will never succeed cannot loop forever; route what exhausts its attempts (or fails permanently) to a dead-letter queue instead of dropping it silently — and treat the DLQ as something to monitor and deliberately reprocess, not an automatic redrive target.

See also: retry amplification · transient vs permanent errors

Do not blindly retry non-idempotent operations

standardintermediate

A retry assumes that repeating the exact same request is safe — but a request can fail after the server has already fully processed it, if only the response was lost on the way back (a timeout is genuinely ambiguous: it does not tell the client whether the request was never received, was received but not yet handled, or was fully handled and only the acknowledgment vanished). If the operation is not idempotent, blindly retrying it in that third case repeats the side effect a second time — charging a card twice, sending a duplicate email, incrementing a counter twice for one real event. Retry logic and idempotent operation design are two separate concerns that only become safe together: the fix for idempotency itself lives in the system-design.delivery-semantics-and-idempotency section — this concept is specifically about respecting that boundary from the retry side.

Think of it as

It is the same risk as re-pressing a "submit payment" button after the page seems to hang. You genuinely cannot tell whether the first click failed to register, is still processing, or actually went through and only the confirmation screen failed to load. Clicking submit again is a retry — and if the payment system doesn't already have a way to recognize "this exact payment was already charged," a second click is a second charge for the exact same order. The button click (the retry) and the payment system's ability to recognize a duplicate (idempotency) are two separate things, and only having both together makes the second click safe.

text
on request timeout:
    if operation.is_idempotent:
        retry safely
    else:
        do NOT blindly retry —
        first add an idempotency guard, or
        surface the ambiguity instead of guessing

What we're doing: Show a client retry wrapper that only retries POST requests carrying an idempotency key, and refuses to blindly retry ones that lack one.

idempotency-gated-retry.txttext
def call_with_retry(request, max_attempts=3):
    if request.method in ("GET", "PUT", "DELETE"):
        safe_to_retry = True
    elif request.method == "POST":
        safe_to_retry = request.has_idempotency_key()
    else:
        safe_to_retry = False

    attempt = 0
    while True:
        try:
            return send(request)
        except TimeoutError:
            attempt += 1
            if not safe_to_retry or attempt >= max_attempts:
                raise  # surface the ambiguity, don't guess
            wait = backoff_with_jitter(attempt)
            sleep(wait)
5
A POST is only marked safe to retry if it explicitly carries an idempotency key — a bare POST with no such guard falls through to "not safe."
15
When the operation isn't safe to retry, the timeout is raised to the caller instead of retried — surfacing the ambiguity honestly rather than guessing that a retry is harmless.

Why this works: This is the concrete decision point the roadmap item is about — the retry mechanism itself (backoff, jitter, max attempts) is identical either way; what changes is whether the wrapper is even allowed to retry, based on whether the operation underneath it is actually safe to repeat.

Adding automatic retries to an HTTP client globally, including for POST requests, without checking for idempotency support

Wrong

text
# client library config
retry_policy = RetryPolicy(
    max_attempts=3,
    methods=["GET", "POST", "PUT", "DELETE"],
)
# applied to every outgoing call, including a
# POST /charge-card with no idempotency key

Better

text
retry_policy = RetryPolicy(
    max_attempts=3,
    methods=["GET", "PUT", "DELETE"],
    # POST only retried when the request itself
    # carries an idempotency key
)
POST /charge-card
Idempotency-Key: order-8842-attempt-1

What you see: A customer is charged twice for one order, and investigation shows the second charge originated from the payment client's own retry logic firing after a slow response — not from the user clicking twice.

Why: A generic retry policy applied uniformly across HTTP methods treats POST the same as GET or PUT, but POST's standard semantics carry no idempotency guarantee — retrying it blindly repeats whatever side effect the first (possibly successful) attempt already caused, which is exactly the failure mode idempotency keys exist to prevent.

Safe to blindly retry, by HTTP method

GET

idempotent by spec

PUT

sets an absolute state

DELETE

usually a no-op repeat

POST

only if idempotency-key guarded

PATCH (relative)

duplicates on retry

  1. GET — idempotent by spec
  2. PUT — sets an absolute state
  3. DELETE — usually a no-op repeat
  4. POST — only if idempotency-key guarded
  5. PATCH (relative) — duplicates on retry

HTTP methods and their standard idempotency expectation for retry safety

HTTP methods and their standard idempotency expectation for retry safety
MethodIdempotent by spec?Safe to blindly retry?
GETYesYes — reading data again has no side effect
PUTYes (sets an absolute state)Yes — applying the same absolute state twice is a no-op the second time
DELETEYesGenerally yes — deleting an already-deleted resource is typically a no-op or a harmless 404
POSTNoNot by default — only safe if the operation is explicitly guarded by an idempotency key or equivalent
PATCH (relative change)NoNot by default — a relative change like "increment by 1" duplicates on retry

Remember: A timeout cannot tell you whether the request actually succeeded — retrying it blindly repeats the side effect if the operation isn't idempotent. GET/PUT/DELETE are safe to retry by default; POST and relative updates are not, unless explicitly guarded by an idempotency key or equivalent (see system-design.delivery-semantics-and-idempotency for the implementation mechanisms).

See also: transient vs permanent errors · idempotent consumer design · idempotency implementation

Advertisement