System Design
Intermediate 12 min read Reliability, security & operations

Timeouts, retries and circuit breakers

In one line

Every network call needs a deadline, retries must be few, jittered and only on safe operations, and a circuit breaker stops you from hammering a dependency that is already down.

What it is

In a distributed system, a slow dependency is more dangerous than a dead one. A dead service fails fast; a slow one holds your threads, connections and memory until your own service also stops responding. Timeouts, retries and circuit breakers are the three basic tools that keep one sick dependency from taking down everything that calls it.

A timeout is the maximum time you wait for a call. A retry repeats a call that failed with a transient error, such as a dropped connection or a 503. A circuit breaker watches the failure rate of a dependency and, when it is too high, fails calls immediately for a short time instead of sending them, then lets a few test calls through to see if the dependency has recovered.

The three work together. Without timeouts, retries never start because calls hang. Without limits on retries, a small outage turns into a retry storm that multiplies load exactly when the dependency is weakest. The circuit breaker is the safety valve that turns 'slow and failing' into 'fast and failing', which is something the caller can handle with a fallback.

Why interviewers ask

Interviewers use these topics to separate people who have run production systems from people who have only drawn boxes. When you say 'the order service calls the payment service', a good interviewer asks: what if payment takes 30 seconds? What if it returns 503 for two minutes? A strong answer names concrete timeouts, a retry policy with backoff and jitter, idempotency for safe retries, a breaker with a fallback, and how deadlines flow across several hops.

How it works

  1. 1Set a timeout on every outbound call: connect timeout (short, around 100 ms to 1 s inside a data centre) and request timeout. Base the request timeout on the dependency's measured latency, for example a bit above its p99.9, not on a round number like 30 seconds. Default HTTP clients in many languages have no timeout at all.
  2. 2Use a deadline, not only per-hop timeouts. The edge sets a total budget (say 800 ms) and passes the remaining time to each downstream call, as gRPC does with deadline propagation. A service that sees only 20 ms left should fail fast instead of starting work nobody will wait for.
  3. 3Retry only transient failures: connection errors, timeouts, 502, 503, 504 and 429 (respecting Retry-After). Do not retry 400, 401, 403 or 404; they will fail again.
  4. 4Retry only operations that are safe to repeat. Reads are safe. Writes are safe only if they are idempotent, usually with an idempotency key the server stores, so a retried 'charge Rs 500' is not charged twice.
  5. 5Use exponential backoff with jitter: wait base x 2^attempt, capped, and pick a random value in that range ('full jitter'). Without jitter, thousands of clients retry at the same instant and create synchronised waves of load.
  6. 6Limit retries in two ways: a small per-call cap (2 or 3 attempts in total) and a retry budget per client, for example retries may add at most 10% extra traffic. Retry at one layer only, usually the one closest to the failing dependency; retrying at every layer multiplies load.
  7. 7Wrap each dependency in a circuit breaker with three states. Closed: calls pass and failures are counted over a sliding window. Open: after the failure rate crosses a threshold (for example 50% of at least 20 calls in 10 seconds), calls fail immediately for a cool-down period. Half-open: after the cool-down, a few trial calls pass; success closes the circuit, failure opens it again.
  8. 8Give every open circuit or exhausted retry a fallback: a cached value, a default, a degraded feature, or a clear error. A fallback is a product decision, so agree it with the product team before the outage.
  9. 9Isolate resources with bulkheads: separate connection pools or concurrency limits per dependency, so a slow recommendation service cannot use up the threads the checkout path needs.
  10. 10Measure it all: timeout counts, retry counts, breaker state changes and fallback rate per dependency should be metrics with alerts.

Worked example · Checkout calling a slow payment provider during a festive sale

  1. A Flipkart-style checkout service runs 200 instances, each with 200 worker threads, handling 4,000 checkouts per second at peak. It calls a payment gateway that normally answers in 300 ms (p99 1.2 s).
  2. The gateway degrades and starts answering in 20 s. With a 30 s client timeout, each checkout holds a thread for 20 s. Needed threads = 4,000/s x 20 s = 80,000, but only 40,000 exist (200 x 200). Within seconds every thread is blocked and even users browsing the cart get errors.
  3. Fix 1, timeout: set the request timeout to 2 s (just above p99). Now each failing call holds a thread for at most 2 s: 4,000 x 2 = 8,000 threads, well within capacity. The rest of the site stays up.
  4. Fix 2, careful retries: one retry after 200-400 ms of jittered backoff, only for connection errors and 503, with the same idempotency key. Total attempts at most 2, so load on the struggling gateway rises at most 2x, and the 10% retry budget caps it much lower in practice.
  5. Fix 3, breaker: when more than 50% of calls fail in a 10 s window, the breaker opens for 30 s. Checkout immediately shows 'Payment is slow, try UPI or another method' and routes new payments to a second gateway. No threads wait at all.
  6. After 30 s the breaker goes half-open, sends 5 trial calls, sees 5 successes and closes. Traffic returns to the primary gateway gradually.
  7. Result: instead of a full-site outage, about 2 minutes of degraded payment choice and no double charges, because every retry reused the idempotency key.

Picture it

Calling a busy customer-care line. You decide in advance you will wait 5 minutes, not forever (timeout). If the call drops you redial, but not instantly and not 50 times (backoff, retry cap). If the line has been dead all morning, you stop calling for an hour and send an email instead (circuit breaker and fallback). And if you redial to repeat a payment instruction, you quote the same reference number so it is not done twice (idempotency key).

Reference code

import random, time

RETRYABLE = {502, 503, 504, 429}

class CircuitOpen(Exception):
    pass

class Breaker:
    def __init__(self, threshold=0.5, min_calls=20, cooldown=30.0):
        self.threshold, self.min_calls, self.cooldown = threshold, min_calls, cooldown
        self.calls = self.failures = 0
        self.opened_at = None

    def before(self):
        if self.opened_at is not None:
            if time.monotonic() - self.opened_at < self.cooldown:
                raise CircuitOpen()          # fail fast, caller uses fallback
            self.opened_at = None            # half-open: let a trial call through
            self.calls = self.failures = 0

    def record(self, ok):
        self.calls += 1
        self.failures += 0 if ok else 1
        if self.calls >= self.min_calls and self.failures / self.calls >= self.threshold:
            self.opened_at = time.monotonic()

def call_with_retry(send, breaker, deadline, attempts=3, base=0.1, cap=2.0):
    for attempt in range(attempts):
        breaker.before()
        remaining = deadline - time.monotonic()
        if remaining <= 0:
            raise TimeoutError("deadline exceeded")
        try:
            status, body = send(timeout=min(remaining, 2.0))
        except (ConnectionError, TimeoutError):
            status, body = None, None
        ok = status is not None and status < 500 and status != 429
        breaker.record(ok)
        if ok:
            return status, body
        if status is not None and status not in RETRYABLE:
            return status, body              # non-retryable (e.g. 500): return as is
        # full jitter backoff, never beyond the deadline
        time.sleep(min(random.uniform(0, min(cap, base * 2 ** attempt)),
                       max(0, deadline - time.monotonic())))
    raise TimeoutError("retries exhausted")

Key patterns

Deadline propagation

When: Request paths with three or more hops

How: Set one total budget at the edge and pass the remaining time downstream (gRPC deadlines or a header). Each hop uses min(own timeout, remaining budget) and drops work whose deadline has passed.

Exponential backoff with full jitter

When: Any retry by many clients against a shared dependency

How: sleep = random(0, min(cap, base x 2^attempt)). Spreads retries in time and avoids synchronised retry waves.

Retry budget

When: High-traffic services where retries could multiply load

How: Track the ratio of retries to requests per client; once retries exceed about 10%, stop retrying and fail fast until the ratio drops.

Bulkhead

When: One service calls several dependencies of different importance

How: Give each dependency its own connection pool or semaphore so a slow one can only exhaust its own share of resources.

Hedged requests

When: Idempotent reads where tail latency matters, such as search fan-out

How: If the first request has not answered by the p95 latency, send a second copy to another replica and take whichever answers first. Costs a few percent extra load, cuts p99 sharply.

Real-world use

AWS Builders' Library

Amazon's article 'Timeouts, retries, and backoff with jitter' explains choosing timeouts from downstream latency percentiles, retrying at a single layer, and why jitter matters. AWS SDKs use exponential backoff with jitter and a client-side retry quota by default.

Netflix Hystrix and Resilience4j

Netflix's Hystrix library popularised circuit breakers, bulkheads and fallbacks in Java services. Hystrix has been in maintenance mode since 2018; Netflix recommended Resilience4j, which most Spring Boot services use today.

Google SRE retry budgets

The Google SRE book describes a per-request retry limit of 3 attempts and a per-client retry budget where retries must stay under about 10% of requests, plus a rule to retry only at the layer just above the failing one.

Envoy and service meshes

Envoy (used by Istio) provides per-route timeouts, retry policies with retry budgets, and outlier detection that ejects unhealthy hosts from the load-balancing pool, which acts like a per-host circuit breaker.

Stripe idempotency keys

Stripe's API accepts an Idempotency-Key header on POST requests and returns the saved result for a repeated key, which is what makes client retries of payment creation safe.

In a project

Your food delivery app's order service calls five dependencies: restaurant menu, pricing, payment, courier assignment and an LLM-based 'order summary' feature. During an IPL final evening, the LLM provider slows to 15 s per call and the order service starts timing out for everyone.

  1. List every outbound call and its measured p50, p99 and p99.9 latency from tracing; set connect and request timeouts per dependency instead of one global value.
  2. Introduce an end-to-end deadline of 1.5 s at the API gateway and propagate the remaining budget in a header or gRPC deadline.
  3. Classify each call as critical (pricing, payment) or optional (LLM summary). Optional calls get a tight timeout (500 ms) and a fallback that simply hides the feature.
  4. Add idempotency keys to payment and order creation so the mobile client and the service can retry safely.
  5. Use one retry with full jitter for reads and idempotent writes, only at the client closest to the dependency, with a 10% retry budget.
  6. Put a circuit breaker and a separate bounded concurrency pool (bulkhead) around each dependency, especially the LLM call.
  7. Run a game day: inject 10 s latency into the LLM and 503s into payment in staging, and confirm the order path stays under its latency SLO.

Impact: A slow optional dependency now costs at most 500 ms and then disappears from the page, instead of taking the whole order flow down. Order success rate during dependency incidents stays above 99%, and on-call engineers see breaker state changes on the dashboard instead of guessing.

Trade-offs

Use it when

  • Any synchronous call over the network, including to databases, caches and third-party APIs
  • Dependencies with known transient failure modes, such as payment gateways, SMS providers and LLM APIs
  • User-facing paths where a fast degraded answer is better than a slow error
  • Optional features that can be hidden when their dependency is unhealthy

Avoid it when

  • Retrying non-idempotent writes without an idempotency key: you risk double charges or duplicate orders
  • Retrying at every layer of a deep call chain: load multiplies (3 attempts at 4 layers = up to 81x)
  • Very aggressive timeouts below the dependency's normal p99: you create failures and retries out of healthy traffic
  • A circuit breaker on a dependency with no fallback and no alternative, where failing fast only changes the error message; still useful for protecting resources, but be honest about what it buys
  • Long synchronous waits for work that should be asynchronous; move it to a queue or a workflow instead of tuning a 60 s timeout

Common mistakes

  • Leaving library default timeouts (often none or very long) on HTTP clients and database drivers.
  • Retrying immediately in a tight loop without backoff or jitter, creating a retry storm that keeps the dependency down.
  • Retrying POST requests that create orders or payments without an idempotency key.
  • Retrying at the gateway, the service and the client library at the same time, multiplying load during an outage.
  • Setting a downstream timeout longer than the upstream one, so the caller has already given up while the callee keeps working.
  • Adding a circuit breaker but no fallback and no metric, so nobody knows it is open and users see a generic error.
  • Sharing one thread or connection pool across all dependencies, so a slow optional service blocks critical ones.

Interview questions

Quick revision

  • Slow is worse than down: concurrency needed = request rate x latency.
  • Every network call gets a connect timeout and a request timeout; base them on p99/p99.9.
  • Propagate one end-to-end deadline; downstream timeouts must be shorter than upstream ones.
  • Retry only transient errors (timeouts, 502/503/504, 429) and only idempotent operations.
  • Backoff: exponential, capped, with full jitter.
  • 2-3 total attempts per call, one retrying layer, and a ~10% retry budget.
  • Breaker states: closed, open (fail fast), half-open (trial calls).
  • Every breaker needs a fallback: cache, default, alternative provider or hidden feature.
  • Bulkheads: separate pools per dependency.
  • Hedged requests cut tail latency for idempotent reads.
  • Alert on breaker opens, retry rate and fallback rate.

Check yourself

1. A service handles 1,000 requests per second and each request calls a dependency. If that dependency's latency rises from 50 ms to 10 s with no timeout, roughly how many concurrent in-flight calls does the service need?

2. Which response is a good candidate for an automatic retry?

3. Each of 4 layers in a call chain retries up to 3 total attempts. In the worst case, how many calls can reach the bottom service for one user request?

4. What happens in the half-open state of a circuit breaker?

5. Why is jitter added to exponential backoff?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free