system_design / resilience / 01_timeouts_retries_backoff.md

Timeouts, retries and backoff

6 interview angles 5 min read source

Timeouts, retries and backoff

The first thing to get right about any network call, and the source of most cascading failures when it’s wrong. The interview question is rarely “what is a retry” — it’s which failures you retry and what stops the retry storm.

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. That’s how one slow dependency takes down a service that isn’t broken.

Set both halves explicitly:

import httpx

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

The pool timeout is the one people omit and then can’t explain: when the pool is exhausted, requests queue inside your process, so the caller waits far longer than the read timeout suggests.

Budget downward. If your endpoint has a 3-second SLA and calls three services, each cannot have a 3-second timeout. Allocate a total budget and pass the remaining time down:

async def handler(request):
    async with asyncio.timeout(3.0):          # the whole request budget
        ...

Timeouts that exceed the caller’s own patience are pure waste — you’re computing a result nobody is still waiting for.

Retry only what can succeed

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

def should_retry(exc: Exception, 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, but honour Retry-After you are being throttled; backing off is the point
500 careful often a genuine bug that retrying won’t fix, and may double a side effect
4xx (400, 401, 403, 404) no the request is wrong; the same request fails again
409, 422 no business-rule rejection

The nuance worth stating: 500 is not automatically retryable. A deterministic server bug returns 500 every time, and if the handler partially applied before failing, retrying duplicates the effect.

The idempotency precondition

Retrying a non-idempotent operation is a correctness bug, not a resilience feature. A retried POST /charges after a timeout can charge twice — and a timeout is exactly the case where you don’t know whether it succeeded.

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

The key must be stable across retries and derived from the business operation, not generated per attempt. See ../../backend/13_architecture_design/18_idempotency_keys.md.

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

Backoff and jitter

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(4),
    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 that matters and the part people leave out. Without it, every client that failed at the same moment retries at the same moment: 1s, 2s, 4s — synchronised waves that re-break the service each time it comes up. This is the thundering herd, and it turns a brief blip into a sustained outage.

wait_exponential_jitter randomises the delay. Full jitter (random.uniform(0, computed_delay)) spreads the hardest and is the safest default.

Cap the delay (max=10) so a retry can’t outlive the caller’s budget, and cap attempts (3-4). More attempts against a genuinely down service just extends the failure.

Retry amplification — the thing seniors name

Retries multiply through a call chain. Service A retries 3x into B, B retries 3x into C: C sees 9 requests for one user action. Four layers deep is 81.

Under partial degradation this is how retries cause the outage rather than surviving it — the retry load is what finishes off a service that was merely slow.

Defences:

  • Retry at one layer only. Usually the outermost client that owns the SLA, or the innermost adapter — pick one deliberately and document it.
  • Retry budgets: cap retries as a fraction of total requests (say 10%), so retry traffic can’t dominate.
  • Circuit breakers to stop retrying a dependency that’s clearly down. See 02_circuit_breakers_and_bulkheads.md.
  • Propagate a deadline so a retry inside an already-expired budget doesn’t happen at all.

Where the policy lives

In the client/adapter layer, not scattered through business logic:

class PaymentClient:
    """One place that owns timeout, retry and breaker policy for this dependency."""
    def __init__(self, http: httpx.AsyncClient, breaker: CircuitBreaker): ...

Each dependency gets its own policy — a fast internal service and a slow third-party API should not share timeouts. Scattering @retry across service methods means you can’t reason about total latency or amplification.

Interview angle

  • “How do you handle a flaky external API?” — timeouts first (connect, read, pool), then retry only transient classes with exponential backoff plus jitter and a capped attempt count, with idempotency keys for anything non-idempotent, all owned by the client layer rather than scattered.
  • “Which errors do you retry?” — connection errors, timeouts, 502/503/504, and 429 honouring Retry-After. Not 4xx. Be careful with 500 — it’s often a deterministic bug, and retrying may duplicate a partial side effect.
  • “Why jitter?” — without it, all clients that failed together retry together, producing synchronised waves that re-break the service on each recovery attempt. Jitter spreads them out; full jitter spreads hardest.
  • “You added retries and the outage got worse. Why?” — retry amplification. Three retries at each of three layers is nine requests per user action, and that load is what pushes a slow service into a dead one. Retry at one layer, add a retry budget, and use a breaker.
  • “A POST timed out. Do you retry?” — only with an idempotency key stable across attempts. A timeout means you don’t know whether it applied, so a blind retry can double-charge. Without a key, surface the uncertainty rather than guessing.
  • “How do you pick timeout values?” — from the dependency’s p99, then budget downward from your own SLA. If your endpoint must answer in 3s and calls three services, they can’t each have a 3s timeout. Propagate the remaining deadline.