Knowledge Guide
HomeSystem DesignMicroservices Patterns

The Retry Pattern A Solution to Unreliable External Resources

The retry pattern re-issues a failed request after waiting a delay that grows exponentially with each attempt and is randomised (jittered), so a single client rides through a transient fault while a struggling dependency gets progressively more room to recover — and a whole fleet of clients does not re-synchronise into a self-reinforcing traffic wave. That one sentence hides three separate decisions: which errors to retry, how long to wait between attempts, and whether the operation is safe to repeat at all. Get any of the three wrong and retries turn a small blip into an outage.

The which is the easy part and the original instinct is right: retry only faults that are plausibly transient — network timeouts, connection resets, HTTP 429/503, a database deadlock (the loser is told to retry). Fail fast on faults that are deterministic for this exact request: HTTP 400 (bad input), 401/403 (auth), 404, 422. Retrying those just repeats a guaranteed failure and adds load. A retry policy is therefore a predicate on the error, not a blanket catch. The hard parts — the delay math and the safety precondition — are where most engineers get burned, and they are what the rest of this page is about.

The delay formula: exponential backoff + jitter

Waiting a fixed delay (retry every 1s) is the classic mistake: it does nothing to relieve a load-related fault, and it causes every client that failed at the same instant to retry at the same instant — forever, in lockstep. Exponential backoff fixes the first problem by widening the gap each attempt:

window(n) = min(cap, base · 2^n)   // n = 0, 1, 2, ...

With base = 100 ms and cap = 2 s, the windows grow 100 → 200 → 400 → 800 → 1600 → 2000 (capped). The cap stops the delay from ballooning to minutes; a bounded max-attempts (or an overall deadline budget) stops it from retrying forever.

Exponential backoff alone still leaves clients synchronised — they just collide at 100 ms, then 300 ms, then 700 ms. Jitter breaks the synchronisation by randomising the actual sleep inside the window. The three standard recipes (from the AWS Architecture Blog, "Exponential Backoff And Jitter"):

The diagram below shows why the randomisation, not the exponential growth, is what actually saves the downstream service.

diagram
diagram

A worked trace: retrying a card charge

An order service posts a $49.99 charge to a payment gateway. Policy: base = 100 ms, cap = 2 s, max = 5 attempts, full jitter, retry only transient errors, and — critically — the same idempotency key chg_order_8842 on every attempt. The gateway is briefly overloaded, then a response gets lost on the network.

AttemptElapsed tRequestWhat happenedwindow = min(2000, 100·2ⁿ)jittered sleep
10 msPOST charge, key ...8842HTTP 503 (overloaded) → transient, retry100 msrandom(0,100) = 42 ms
2150 msPOST charge, key ...8842HTTP 503 again → transient, retry200 msrandom(0,200) = 173 ms
3331 msPOST charge, key ...8842Gateway charges the card, then the 200 response is lost; client sees a read timeout at 2331 ms → transient, retry400 msrandom(0,400) = 290 ms
42621 msPOST charge, key ...8842Gateway recognises the key, replays the stored result → HTTP 200 {status: succeeded}done

The card was charged exactly once, even though the client sent four requests and never got a response to the one that actually succeeded. Now delete the idempotency key: attempt 4 would look like a brand-new charge, and the customer is billed $49.99 twice. That is the central danger of blind retries — a timeout is not a failure, it is an unknown. The request may have fully succeeded; you simply did not hear back.

Idempotency: the precondition blind retries ignore

An operation is idempotent if doing it N times has the same effect as doing it once. Retries are only safe on idempotent operations. GET/PUT/DELETE are naturally idempotent; a raw POST that creates a resource or moves money is not, so you make it idempotent with an idempotency key: the client generates a unique key per logical operation and sends it on every attempt; the server stores key → result the first time and replays the stored result for any repeat. Stripe, PayPal, and AWS all expose exactly this. The same idea inside a system is the idempotent consumer (dedupe by message ID) for queues.

Below, the same correct policy in Java and Go — transient-only, one key, budget- and deadline-aware.

// Java: full-jitter exponential backoff. Retries ONLY transient faults,
// reuses ONE idempotency key, and is capped by a retry budget.
int maxAttempts = 5;
long baseMs = 100, capMs = 2000;
for (int attempt = 0; attempt < maxAttempts; attempt++) {
    try {
        return client.charge(request, idemKey);   // same key on every attempt
    } catch (TransientException e) {               // 5xx / timeout / deadlock
        boolean last = attempt == maxAttempts - 1;
        if (last || !budget.tryAcquire())          // budget stops retry storms
            throw e;
        long window = Math.min(capMs, baseMs << attempt);   // base * 2^attempt
        long sleep  = ThreadLocalRandom.current().nextLong(window + 1); // [0, window]
        Thread.sleep(sleep);
    }
    // PermanentException (400 / 401) is NOT caught -> it propagates immediately
}
throw new IllegalStateException("unreachable");
// Go: same policy — transient-only, single idempotency key, budget + deadline aware.
const maxAttempts = 5
base, cap := 100*time.Millisecond, 2*time.Second
var lastErr error
for attempt := 0; attempt < maxAttempts; attempt++ {
    resp, err := client.Charge(ctx, req, idemKey) // same idemKey every attempt
    if err == nil {
        return resp, nil
    }
    if !isTransient(err) {              // 400 / 401 / validation -> give up now
        return nil, err
    }
    lastErr = err
    if attempt == maxAttempts-1 || !budget.Allow() {
        break
    }
    window := time.Duration(1<<attempt) * base
    if window > cap {
        window = cap
    }
    sleep := time.Duration(rand.Int63n(int64(window) + 1)) // full jitter [0, window]
    select {
    case <-time.After(sleep):
    case <-ctx.Done():                 // respect the overall time budget
        return nil, ctx.Err()
    }
}
return nil, lastErr

Why the naive version is wrong

The tempting version — while (true) { try return call(); catch (Exception e) { sleep(1000); } } — is wrong four ways at once: (1) it loops forever with no cap; (2) it retries every exception, including 400/401 that will never succeed; (3) the fixed 1s delay synchronises all clients into the retry storm the diagram shows; and (4) with no idempotency key it double-charges on the lost-response case in the trace above. Each fix in the code — bounded attempts, a transient predicate, jittered exponential backoff, and a shared key — closes one of those holes.

Retry budgets and the retry-storm multiplier

Retries are dangerous because they multiply through a call chain. If the edge service retries 3× and it calls a service that also retries 3×, one user action can hit the deepest dependency 9 times; across a chain of depth N where each tier retries r times, worst-case amplification is rⁿ. The moment a shared dependency slows down, every layer starts retrying, load spikes exactly when the system is weakest, and the slowdown becomes a full outage. This is retry amplification, and it has caused real cascading failures.

Two disciplines contain it. First, retry at only one layer — usually the one closest to the fault (or the outermost edge) — and let inner calls fail fast. Second, add a retry budget: cap retries as a fraction of successful traffic (gRPC and Finagle default to ~10–20%) using a token bucket, so retries can absorb a blip but can never exceed a small multiple of normal load. That is the budget.tryAcquire() / budget.Allow() gate in the code. When the budget is exhausted, you stop retrying — which is exactly the hand-off point to a circuit breaker.

Pitfalls

When to use it — and when to reach for something else

Reach for retries when the failure signal is transient and independent: sporadic timeouts, 429/503, connection resets, deadlock losers — a fault where "try again in a moment" has a genuinely good chance of hitting a healthy instance. Concrete green lights: error rate is low and spiky (not sustained), the operation is idempotent (or you can add a key), and there is time in the deadline budget to wait.

Trade-offs versus the named alternatives:

Rule of thumb a senior engineer applies: retry inline for sub-second transient faults on idempotent calls, behind a budget and a breaker; degrade or fail-fast on the read path; push durable writes to a queue. Blind unbounded retries on a non-idempotent call is never the answer.

Takeaways


Re-authored and deepened for this guide. Sources: Marc Brooker & the AWS Architecture Blog, "Exponential Backoff And Jitter"; the AWS Builders' Library, "Timeouts, retries, and backoff with jitter"; Michael Nygard, Release It! (2nd ed.) on retry storms and circuit breakers; the Stripe and AWS API idempotency-key documentation; and the gRPC / Finagle retry-budget designs.

🤖 Don't fully get this? Learn it with Claude

Stuck on The Retry Pattern A Solution to Unreliable External Resources? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🪜 Hint ladder (no spoilers)

Progressively stronger hints — you still solve it.

I'm working on the problem **The Retry Pattern A Solution to Unreliable External Resources** (System Design). Give me a HINT LADDER: start with the tiniest nudge, then wait. Only reveal the next, stronger hint when I ask. Do NOT show the full solution unless I type 'show solution'. Keep me doing the thinking. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🎨 Explain the approach visually

See the technique, not just code.

Explain the optimal approach to **The Retry Pattern A Solution to Unreliable External Resources** with a VISUAL walkthrough: trace it on a small concrete example using ASCII art / a step-by-step diagram, narrate what changes each step, then give time & space complexity with a one-line derivation. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔍 Review my solution

Catch bugs, edge cases, sub-optimality.

I'll paste my solution to **The Retry Pattern A Solution to Unreliable External Resources**. Review it for correctness, missed edge cases, and time/space complexity, then coach me toward the optimal — don't just rewrite it. Ask me to paste my code now. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🔁 Drill the pattern

Lock in recognition with look-alikes.

Give me 2 problems that use the SAME underlying pattern as **The Retry Pattern A Solution to Unreliable External Resources**. For each, let me attempt first, then review my answer and name the trigger signal that reveals the pattern. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes