Exponential Backoff with Jitter
build a correct retry in 12 lines
System designsystem design / resilience
Build it
import random, time
def call_with_retry(fn, attempts=5, base=0.1, cap=10.0):
for i in range(attempts):
try:
return fn()
except Retryable as e:
if i == attempts - 1:
raise
ceiling = min(cap, base * (2 ** i))
time.sleep(random.uniform(0, ceiling))
What each line teaches
except Retryable— the retry decision happens BEFORE the sleep math. 500/502/503/504, timeouts and connection resets are retryable. 400/401/403/422 are not: retrying a bad request just burns quota. 429 is retryable but only withRetry-Afterhonored.if i == attempts - 1: raise— a bounded loop. Unbounded retry is how a blip becomes an outage.base * (2 ** i)— 0.1, 0.2, 0.4, 0.8, 1.6s. Exponential gives the dependency time to actually recover instead of hammering it.min(cap, ...)— without a cap, attempt 12 sleeps 7 minutes and your request holds a connection + thread that whole time.random.uniform(0, ceiling)— this is the line most people skip, and it's the important one.
Why the jitter line matters
A pod dies. 500 clients get an error at the same millisecond. Without jitter they all sleep exactly 0.1s, then all retry at exactly t+0.1s — you rebuilt the spike that killed you, but now synchronized. Recovery becomes impossible: every wave re-kills the service that was 80% of the way back up.
random.uniform(0, ceiling) (AWS calls this "full jitter") spreads those 500 retries evenly across the window. AWS's own benchmark: full jitter cut total work and total completion time versus plain exponential backoff, because the server stopped seeing synchronized waves.
Note it picks from 0..ceiling, NOT ceiling ± a bit. Half your retries land early — that's fine and intentional; the spread is what you're buying.
The nuance that bites intermediates: retry amplification
Retries multiply through layers. Client retries 3× → API gateway retries 3× → service retries 3× on the DB call. One user click can become 27 database queries. During an incident that's a self-inflicted DDoS.
Rules that fix it:
- Retry at ONE layer — usually the one closest to the failure. Middle layers pass the error up.
- Use a retry budget: only allow retries while they're under ~10% of total requests (gRPC and Envoy both implement this). At 10% you're already at 1.1× load; without a budget you hit 4×.
- Retry only idempotent operations. A POST /charge that timed out may have SUCCEEDED — the response was lost, not the work. Retry it with an idempotency key or don't retry it at all.
- Set a total deadline, not just an attempt count.
attempts=5with a 30s timeout each = 150s of a user waiting. Budget the whole call: 3s total, spend it however.
Quick numbers to keep in your head
base=0.1, cap=10, 5 attempts → worst-case added latency ≈ 0.1+0.2+0.4+0.8+1.6 = 3.1s of sleeping, averaging ~1.5s with full jitter (since each sleep averages half its ceiling).
Your exercise
Take the 12-line function above and add two things:
- A
deadlineparameter (absolute time). Before each sleep, ifnow + sleep > deadline, stop and raise immediately instead of sleeping past it. - Honor
Retry-Afteron 429: if the server sent a value, sleep that instead of your computed jitter.
Then answer this: your service calls a payment API that times out after 5s. You retry 3×. Is that safe? What single thing would you need from the payment API to make it safe — and what happens to the customer's card if you retry without it?