System design / Resilience / 01_timeouts_retries_backoff.md

Timeouts, retries and backoff

Updated 7 interview angles 12 min read source
On this page8
  1. The mechanism: a retry is load
  2. Timeouts come first
  3. Retry only what can succeed
  4. Backoff, jitter, and a budget
  5. Where it breaks in production
  6. The trade not taken
  7. Related
  8. Interview angle

Timeouts, retries and backoff

Article.

A retry is a bet: that the failure was transient, and that trying again costs less than giving up. Most of the time the bet is good, which is why retries are everywhere and mostly invisible.

The question worth being able to answer is when the bet is wrong — because the case where it is wrong is exactly the case where you are already having an outage, and a retry policy tuned for the good case will finish the job.

The mechanism: a retry is load

Every resilience discussion treats retries as a client-side concern. From the server’s side they are indistinguishable from traffic.

text
service degrades  ->  calls time out
                        ↓
                      clients retry
                        ↓
                      load increases
                        ↓
                      service degrades further
                        ↑___________________|

That loop is the whole story. Under normal failures — one dead node, a dropped packet, a restarting pod — the dependency has spare capacity, the retry lands on a healthy path, and the bet pays. Under saturation the dependency has no spare capacity, and the retry is the thing consuming it.

So the same policy is correct and catastrophic depending on a condition the client cannot see. That is why the interesting mechanisms below — budgets, breakers, deadline propagation — are all ways of inferring that condition.

Metastable failure

The loop has a property that makes it worse than it first looks: it can sustain itself after its cause is gone.

text
t0   a deploy makes the service 3x slower
t1   timeouts, retries begin
t2   retry load = 4x normal, service saturates
t3   the deploy is rolled back
t4   still down

At t4 the original trigger is gone and the system stays broken, because the retry traffic alone is now enough to keep it saturated. This is a metastable failure — a stable bad state the system will not leave on its own.

The practical consequence: you cannot recover by fixing the trigger. You have to break the loop — shed load, drain queues, or turn retries off — before the service can come back. Teams that have not seen this spend the outage re-deploying and wondering why nothing helps.

The arithmetic of amplification

Retries multiply through a call chain, and the numbers are worse than intuition suggests because they compound rather than add.

text
A --3x--> B --3x--> C --3x--> D

one user action reaches D as 27 requests
Layers retrying 3x Requests at the bottom
1 3
2 9
3 27
4 81

The service that dies is the one furthest from the user, and it dies from load generated by clients that each believe they are being modest. Nobody’s policy looks unreasonable in code review; the product of them is a denial of service you built yourself.

Timeouts come first

A retry policy without a timeout is not a resilience strategy. Without a timeout the call hangs, the worker is held, the pool drains, and the failure spreads upstream — one slow dependency taking down a service that is not broken.

python
import httpx

client = httpx.AsyncClient(
    timeout=httpx.Timeout(connect=2.0, read=10.0, write=5.0, pool=1.0),
)
Timeout Bounds
connect the TCP/TLS handshake; keep short, 1-3s
read waiting for response bytes; size to the dependency’s p99
write sending the request body
pool waiting for a free connection from the pool

pool is the one people omit and then cannot explain. When the pool is exhausted, requests queue inside your own process, so the caller waits far longer than the read timeout suggests and the latency has no server-side trace.

Budget downward, do not set per-call

If your endpoint has a 3-second SLA and calls three services, they cannot each have a 3-second timeout. Allocate once and pass the remainder down:

python
class Deadline:
    def __init__(self, budget: float) -> None:
        self.expires_at = loop.time() + budget

    def remaining(self) -> float:
        return max(0.0, self.expires_at - loop.time())


async def handler(request) -> Response:
    dl = Deadline(3.0)
    profile = await fetch_profile(request.user_id, dl)   # each call
    prefs = await fetch_prefs(request.user_id, dl)       # sees what
    return render(profile, prefs)                        # is left


async def fetch_profile(user_id: str, dl: Deadline) -> dict:
    left = dl.remaining()
    if left < 0.05:
        raise DeadlineExceeded          # do not start what cannot finish
    resp = await client.get(f"/users/{user_id}", timeout=left)
    return resp.json()

The check before the call is the point. A retry inside an already-expired budget is pure waste — you are computing a result nobody is waiting for, using capacity the next request needs. Deadline propagation is the cheapest defence against amplification, because an expired deadline stops retries at every layer at once without any layer coordinating.

gRPC propagates deadlines natively; over HTTP, send the remaining budget in a header and have the callee honour it.

Retry only what can succeed

python
RETRYABLE = (
    httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout,
    httpx.RemoteProtocolError,
)

def should_retry(exc: Exception | None, response: httpx.Response | None) -> bool:
    if isinstance(exc, RETRYABLE):
        return True
    if response is None:
        return False
    return response.status_code in {429, 502, 503, 504}
Class Retry? Why
Connection error, timeout yes transient by nature
502, 503, 504 yes upstream transient
429 yes, honour Retry-After you are being throttled
500 careful often a deterministic bug
4xx no the request is wrong; it fails again
409, 422 no a business rule rejected it

The nuance worth stating unprompted: 500 is not automatically retryable. A deterministic server bug returns 500 every time, so retrying converts one failure into four and adds load during an incident. Worse, if the handler partially applied before failing, the retry duplicates that part.

Honour Retry-After

A 429 is the dependency telling you its capacity, which is more information than your backoff formula has. Ignoring it and applying your own schedule is how a rate limit becomes an outage.

python
def retry_after_seconds(resp: httpx.Response) -> float | None:
    value = resp.headers.get("Retry-After")
    if value is None:
        return None
    if value.isdigit():                       # delta-seconds form
        return float(value)
    when = parsedate_to_datetime(value)       # HTTP-date form
    return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())

Both forms are legal and servers use both, so parsing only the integer form silently discards the header from the servers that send a date.

The idempotency precondition

Retrying a non-idempotent operation is a correctness bug, not a resilience feature. A timeout is precisely the case where you do not know whether the call applied, so a blind retry on POST /charges can charge twice.

python
await client.post(
    "/charges",
    json=payload,
    headers={"Idempotency-Key": str(order.idempotency_key)},
)

The key must be stable across attempts and derived from the business operation — generating it inside the retry loop defeats the entire mechanism. See Idempotency keys.

Safe without a key: GET, PUT with the same body, DELETE. Not safe: a POST that creates something, anything that increments, anything that sends a message.

Backoff, jitter, and a budget

python
from tenacity import (
    retry, retry_if_exception_type, stop_after_attempt,
    wait_exponential_jitter, before_sleep_log,
)

@retry(
    retry=retry_if_exception_type(RETRYABLE),
    wait=wait_exponential_jitter(initial=0.5, max=10, jitter=2),
    stop=stop_after_attempt(3),
    before_sleep=before_sleep_log(logger, logging.WARNING),
    reraise=True,
)
async def fetch(url: str) -> httpx.Response:
    resp = await client.get(url)
    resp.raise_for_status()
    return resp

Jitter is the part people leave out and the part that matters. Without it, every client that failed at the same instant retries at the same instant:

text
no jitter          full jitter

t+1s  ████████     t+0..1s  ██  █ ██ █
t+2s  ████████     t+0..2s  █ ██  █  █
t+4s  ████████     t+0..4s  █  █ █  ██

The left column is a thundering herd — synchronised waves that re-break the service at each recovery attempt, so it never gets a quiet moment to catch up. Full jitter (random.uniform(0, delay)) spreads hardest and is the right default; the exponential part alone does nothing about synchronisation.

Cap the delay so a retry cannot outlive the caller’s budget, and cap attempts at three. More attempts against a genuinely dead service is just more load.

The retry budget

Backoff limits how fast one client retries. It does nothing about the total, which is what the dependency experiences. A budget does:

python
class RetryBudget:
    """Allow retries only while they stay a small fraction of traffic."""

    def __init__(self, ratio: float = 0.1, window: int = 1000) -> None:
        self.ratio = ratio
        self.calls = deque(maxlen=window)     # True = retry, False = initial

    def record(self, was_retry: bool) -> None:
        self.calls.append(was_retry)

    def allow_retry(self) -> bool:
        if len(self.calls) < 50:              # too little data to judge
            return True
        return sum(self.calls) / len(self.calls) < self.ratio

Under normal conditions a few percent of calls fail, the ratio stays low, and every retry is permitted. Under a broad outage nearly everything fails, the ratio hits the cap, and retries stop automatically — exactly when they are harmful and exactly when a per-call policy would be trying hardest.

That inversion is the argument for budgets over attempt counts: a budget is aware of the condition an individual call cannot see.

Where it breaks in production

Retries at every layer

Each team adds a sensible three-attempt policy to their own client. Nobody added an unreasonable one, and the bottom service sees 27x.

Fix it by deciding once, for the whole call chain, where retries live — usually the outermost client that owns the SLA, or the innermost adapter that knows the dependency. Write it down, because the failure mode is invisible in any single repository.

The library that also retries

The subtle version of the same bug: botocore retries, httpx transports can, gRPC has a service config, the database driver reconnects. A @retry decorator on top of a client that already retries three times gives nine, and the code shows three.

Read the defaults of every client library in the path. This is the most common source of amplification that nobody wrote.

A timeout longer than the caller’s patience

A 30-second timeout on a service whose caller gives up at 5 seconds means 25 seconds of work on every failed request that nobody will read, consuming capacity while a queue builds behind it. Timeouts are only meaningful relative to the budget above them.

Retrying the 500 that half-applied

The handler wrote a row, then failed on the second write and returned 500. The retry writes the row again. The 500 was honest; the retry was the bug, and it shows up as duplicate records with no error in any log.

The recovery that will not take

If the loop above has set in, restarting the service does not help — it comes up into a full queue and dies again. Recovery means breaking the feedback loop first: shed load at the edge, drain the queue, or disable retries globally, and only then restore traffic. Having a switch that turns retries off is worth more during an incident than any tuning of the backoff curve.

The trade not taken

Two alternatives worth naming, because “add retries” is not the only answer.

Fail fast and let the caller decide. A retry hides a failure inside a latency increase, which is the wrong trade for anything a human is waiting on. An immediate error the UI can act on beats twelve seconds of silence followed by the same error. Retry when the caller is a machine with time to spare; surface it when the caller is a person.

Make it asynchronous instead. If the work does not have to happen inside the request, a queue gives you retries with none of the amplification: the retry load is bounded by consumer concurrency rather than by user traffic, backoff can be minutes instead of milliseconds, and a dead-letter queue is a better place to land than a 500. Most “we need a robust retry policy” conversations are really about work that should not have been synchronous.

And the opposite trade, for tail latency rather than failure: hedged requests — send a second request once the first exceeds p95, take whichever answers, cancel the other. It spends a few percent extra load to cut the tail, which is the right trade for a read-only, idempotent call and a catastrophic one for anything that writes.

Interview angle 7

  • “How do you handle a flaky external API?” — timeouts first, including the pool timeout, then retry only transient classes with exponential backoff plus jitter and a low attempt cap, idempotency keys for anything that writes, and the whole policy owned by the client layer rather than scattered.
    • Follow-up: “Where do the timeout values come from?” — the dependency’s p99 for the read timeout, then budget downward from your own SLA. If you must answer in 3 seconds and call three services, they cannot each have 3 seconds; propagate the remaining deadline and refuse to start a call that cannot finish inside it.
  • “You added retries and the outage got worse. Why?” — because a retry is load, and under saturation it is the load. Three attempts at each of three layers is 27 requests per user action, which is what pushes a slow service into a dead one.
    • Follow-up: “So how do you stop that?” — decide once where in the chain retries live, propagate deadlines so an expired budget stops them everywhere at once, and use a retry budget rather than an attempt count so retries switch off automatically when the failure rate is broad.
  • “You rolled back the bad deploy and it is still down.” — a metastable failure: the retry traffic alone is now enough to keep the service saturated, so removing the trigger does not help. You have to break the loop — shed load, drain the queue, or turn retries off — and then restore traffic.
  • “Why jitter?” — without it, every client that failed together retries together, and the synchronised waves re-break the service on each recovery attempt. Exponential backoff alone does not desynchronise anything; full jitter does.
  • “Which errors do you retry?” — connection errors, timeouts, 502/503/504, and 429 honouring Retry-After in both its integer and HTTP-date forms. Not 4xx. And 500 only with care: it is often a deterministic bug, and if the handler partially applied, retrying duplicates that part.
  • “A POST timed out. Do you retry?” — only with an idempotency key that is stable across attempts. A timeout means you do not know whether it applied, so a blind retry can double-charge. Without a key, surface the uncertainty rather than guessing.
  • “When would you not retry at all?” — when a person is waiting. A retry converts a failure into latency, and an immediate error the UI can act on beats twelve seconds of silence and then the same error. If the work does not need to be synchronous, a queue gives you retries with none of the amplification.