Retries are the difference between a flaky dependency and an outage. But a retry loop that isn't disciplined becomes a retry storm — everyone retrying in lockstep can take down the very service you're trying to reach. The trace:
Retry-After header must be respected, not improvised.delay = min(cap, base × 2^attempt). With base 100ms, attempts land at 100ms, 200ms, 400ms, 800ms, 1600ms, capped at 5s (or the AWS-style 20s). This is where naive implementations die: without a cap, attempt 10 waits 51 seconds; with a cap, the spacing plateaus.random(0, delay) (full jitter) or delay/2 + random(0, delay/2) (equal jitter). The math matters: without jitter, 1,000 clients that failed at the same moment all retry at the same moment, so the second wave is as synchronized as the first. Full jitter spreads the wave across the full backoff window — the retry rate decays smoothly instead of spiking.deadline = start + 30s (duration budget) or attempts < 5 (count budget). The budget exists so a user request dies instead of hanging: a 30s budget at the above schedule allows ~5–6 attempts total. A request that would exceed the budget is not retried — it returns the last error.RetryBudget limiting retries to 10% of request volume) exist: each layer's retry must not multiply the layers beneath it.attempt, delay = 0, base
while attempts < max and now < deadline:
resp = send(request) # ~1 RTT + server time
if ok: return resp
if resp.headers["Retry-After"]: delay = that value
else: delay = random(0, min(cap, base * 2**attempt))
sleep(delay)
attempt += 1
return last_errorThe numbers that matter: base 100ms, cap 5–20s, full jitter, a duration budget of 30s (5–6 attempts), and a hard rule that only idempotent requests are retried. That combination keeps a failing dependency costing you ~30s of latency, not a stampede and not a hang — and it is exactly what every SDK from AWS to gRPC ships by default.