Be Software Architecture Wise
Be Wise Apps

Leer en español · Lire en français · Auf Deutsch lesen

Section 9.4 of 9.8 · Advanced · 32 min read

Timeouts, retries, circuit breakers, bulkheads and graceful degradation

Video lesson

5 min · narrated, with captions
Listen only (audio)
Read the transcript

Welcome to Section nine point four. In a distributed system, some dependency is always slow or failing. The question is not how to avoid failure, but how to stop a local failure becoming a global one.

Imagine phoning a supplier who never picks up. If you hold the line all day, you get nothing else done. If every colleague does the same, the whole office stops. Sensible offices hang up after a few rings. That's a timeout. They try again a little later, but not endlessly. That's a retry with a pause. They stop calling a supplier who is clearly closed, and check again in an hour. That's a circuit breaker. They make sure one supplier can't tie up every phone. That's a bulkhead. And they have a plan B, such as yesterday's price list. That's a fallback. Software services need exactly the same habits.

Here's why this matters. The food delivery order service has twelve hundred threads across six instances. At lunchtime, by Little's law, only fifty are busy: thirty five for checkouts, which call the payment provider, and fifteen for everything else. At twelve thirty one, the payment provider stops answering but keeps connections open. The payment client has no timeout, so every checkout holds its thread indefinitely. A hundred more threads are stuck every second. Within about twelve seconds, every thread is waiting on the payment provider. Now order history, basket and tracking fail too, though they never touch payments. A failure in twenty five per cent of the traffic has taken out a hundred per cent. With a two second timeout, checkout uses at most two hundred threads, leaving about nine hundred and eighty five for everything else. Only checkout fails, and it fails clearly. A bulkhead goes further. Limiting payment calls to forty threads per instance guarantees at least nine hundred and sixty threads for everything else, whatever the timeouts.

Timeouts should shrink as you go down a call chain. A deadline makes this automatic. The mobile app sets a deadline of one thousand milliseconds for the whole request. The gateway spends twenty milliseconds and passes on the time left: nine hundred and eighty. The order service spends fifty, then three hundred and twenty on other calls, and passes six hundred and ten to pricing. Pricing needs about eight hundred milliseconds, so it fails fast at once, instead of doing work nobody will wait for.

Retries turn brief glitches into successes. But retries are also load. Retry only transient errors, such as timeouts or a five oh three, and only operations that are idempotent, because a timed-out request may have succeeded. Retry two or three times, waiting longer each time: exponential backoff, up to a cap. Add jitter, a random wait. Otherwise a thousand clients that failed together retry together, in waves that keep the dependency down. And retries multiply across layers. Three attempts at three layers is twenty seven calls. So retry at one layer only, and cap retries at about ten per cent of requests.

A circuit breaker wraps calls to a dependency. While it's closed, calls pass through and failures are counted. When failures cross a threshold, for example half of the last twenty calls, the breaker opens. Now every call fails fast, without touching the network. That protects the caller's threads and gives the dependency room to recover. After a wait, for example thirty seconds, it becomes half-open, and lets a few trial calls through. If they succeed, it closes again. If a trial call fails, it opens for another period.

A bulkhead takes its name from a ship's watertight compartments. Here, each dependency gets its own bounded pool of threads. The payment provider is slow, so its pool fills up, and further payment calls are rejected at once. But restaurant and courier calls carry on normally. A timeout limits how long a call can hold resources. A bulkhead limits how many can be held at once.

When a call fails, the caller must still respond. A fallback might be the last known menu, an estimated delivery time, popular dishes instead of personal recommendations, or a clear, fast error. That's graceful degradation: the core journey keeps working while extras step aside. A fallback must be simple, independent of the failed part, and regularly tested. Security is the exception. If the authorisation service is unreachable, the safe fallback is to deny. Finally, check that an operation is idempotent before you retry it. A retry policy on a payment call without an idempotency key isn't a resilience pattern. It's a double charging generator.

The full section adds hedged requests and a checklist for every remote call. Next, in Section nine point five, we look at how good is good enough. See you there.

What you need to know

Failure is normal; spreading it is optional

In a distributed system some dependency is always slow or failing: a payment provider has a bad minute, a database fails over, a network link drops packets, a deployment goes wrong. Section 6.1 showed why these partial failures cannot be prevented and why a caller often cannot even tell what happened. The architectural question is therefore not how to avoid failure but how to stop a local failure becoming a global one. A cascading failure is the typical way systems die: a slow dependency makes its callers slow, their resources fill with waiting requests, they in turn become slow for their own callers, and within seconds a problem in one minor component has taken out the whole platform.

Michael Nygard catalogued the patterns that interrupt such chains in Release It! (2007; second edition 2018), drawing on outages he had investigated. His central observation is that integration points, every place where one system calls another, are the main source of instability, and that each one needs deliberate protection. The patterns in this section (timeouts, deadlines, retries with backoff, circuit breakers, bulkheads, fallbacks, idempotency and hedging) are the standard toolkit. They are tactics in the sense of Section 2.5: each is a single decision aimed at availability, and each has a cost.

Timeouts: the first and most important pattern

A timeout bounds how long a caller waits. Without one, a call to a dependency that never answers holds a thread, a connection and memory for as long as the operating system keeps the connection open, which can be many minutes. Many client libraries have no timeout by default (Python's widely used requests library waits indefinitely unless told otherwise), so the absence of a timeout is a design decision made by omission. The worked example below shows the consequence.

Two timeouts usually matter. The connect timeout bounds establishing a connection and can be short, a few hundred milliseconds inside a data centre, because a healthy server accepts connections almost immediately. The request timeout bounds the whole exchange and should come from measurement: slightly above the dependency's high percentile latency (p99 or p99.9) under normal load, so that it cuts off pathological waits without failing ordinary slow requests. It must also fit inside the caller's own budget. If the gateway gives up after 2 seconds but the order service waits 10 seconds for pricing, the order service keeps working for 8 seconds on a request nobody is waiting for. Timeouts should therefore shrink as you go down a call chain, which is the idea behind deadlines.

Deadlines and their propagation

A deadline is an absolute point in time by which the whole request must be finished. Deadline propagation passes the remaining time to every downstream call, so that each service knows how much budget is left rather than applying a fixed timeout of its own. gRPC builds this in: a client sets a deadline, the remaining time travels with the call, and a server can check whether the deadline has passed and stop work. Over HTTP, teams propagate a header with the remaining milliseconds or the absolute deadline, ideally through the same middleware that propagates tracing context (Section 9.6).

Propagation gives two benefits. A service can fail fast when the time left is obviously too short for the work, rather than starting a database query that cannot finish in time. And when the user gives up, the work below stops too, instead of continuing to consume capacity during exactly the overload that caused the delay. This is load shedding of dead requests, one of the most effective protections a system under stress can have.

A deadline propagated through a call chain

A request starts with a 1,000 ms deadline; each service passes on the remaining time, and a service that cannot finish in the time left fails fast instead of starting workMobile appdeadline 1,000 msAPI gatewayremaining 980Order serviceremaining 930Pricingremaining 610sendsendsend20 ms spent50 ms spentthen 320 ms onother callsneeds about 800 ms:fails fast at onceEach hop sends the time left, not a fresh timeout of its own
Each hop passes on the time remaining; a service that cannot finish in the time left fails at once rather than doing work nobody will wait for.

Worked example

Worked example: how one missing timeout becomes a full outage

The food-delivery order service runs 6 instances with 200 worker threads each: 1,200 threads. At lunchtime it receives 400 requests per second: 100 checkouts, which call the payment provider (normally 300 ms, so a checkout holds its thread for about 350 ms in all), and 300 other requests (order history, basket, tracking) taking 50 ms. The payment client has no timeout configured. At 12:31 the payment provider stops answering but keeps connections open.

Threads busy normally (Little's law)Checkout 100 × 0.35 s = 35; other traffic 300 × 0.05 s = 15; total 50 of 1,200 (about 4%)
After 12:31Every checkout now holds its thread indefinitely: 100 more threads are stuck every second
Time to exhaustion(1,200 − 15 − 35) ÷ 100 ≈ 11.5 seconds: within about 12 seconds every thread is waiting on the payment provider
ConsequenceOrder history, basket and tracking, which never call payments, now queue for threads and time out at the gateway. A failure in 25% of the traffic has taken out 100%
And upstreamThe gateway's connections to the order service pile up in the same way; health checks time out, instances are marked unhealthy, and if the liveness probe is affected they are restarted, cold, into the same conditions
With a 2-second timeoutCheckout threads in use: 100 × 2 s = 200 across the fleet (Little's law). About 985 threads remain for other traffic, so only checkout fails, and it fails with a clear error after 2 s
Adding a circuit breakerOnce, say, half of the last 20 calls have failed, the breaker opens: checkouts fail in milliseconds and use almost no threads; a trial call every 30 s detects recovery
Adding a bulkheadLimiting payment calls to 40 threads per instance (240 in total) guarantees at least 960 threads for everything else, whatever timeouts are set
Adding a fallbackCheckout offers the customer another payment method, or explains that card payments are delayed, instead of a generic error

The arithmetic is Little's law again: threads in use = request rate × time each request holds a thread. Without a timeout, the holding time is unbounded, so the threads needed are unbounded.

Timeouts limit how long; bulkheads limit how many; circuit breakers stop calling altogether. Each closes a different gap, which is why they are used together.

Nygard's Release It! describes real outages that followed exactly this pattern, often triggered by an integration point nobody considered critical.

Thread-pool exhaustion

A slow payment provider holds every worker thread of the order service, so requests that do not need payments, and calls to a healthy database, also failCheckoutOrder history,trackingOrder serviceall 200 threads waitingon payment callsPayment providerstops answering;normally 0.3 sDatabasehealthy, but nothread is free to call itboth fail:no free threads
Without a timeout, a slow payment provider holds every thread of the order service, so even requests that never touch payments fail.

Retries with exponential backoff and jitter

Many failures are transient: a dropped packet, a connection reset during a failover, a brief 503 from an overloaded instance. Retrying turns these into successes the user never sees. But retries are also load, and badly designed retries are among the most common causes of outages. Four rules make them safe. Retry only transient errors (timeouts, connection failures, 502, 503, 504, and 429 after the indicated delay), never errors that will recur, such as 400 or 404. Retry only idempotent operations or those carrying an idempotency key, because a timed-out request may have succeeded. Retry a small number of times, typically two or three. And wait between attempts, with exponential backoff: a delay that doubles with each attempt, up to a cap.

Backoff alone is not enough. If a thousand clients fail at the same moment, as they do when a dependency blips, they all retry after 100 ms, then all after 200 ms, arriving in synchronised waves that can keep the dependency down. Marc Brooker's article Exponential Backoff And Jitter (AWS Architecture Blog, 2015) simulated this and showed that adding randomness, jitter, greatly reduces both the total work and the time to complete. His recommended full jitter sleeps for a random time between zero and the exponential bound: sleep = random(0, min(cap, base × 2attempt)). Jitter belongs on anything many clients do at once: retries, reconnections, cache expiry times and scheduled jobs.

import random, time

class TransientError(Exception):
    """Timeouts, connection resets, 502, 503 and 504 responses."""

def call_with_retries(fn, attempts=3, base=0.1, cap=2.0):
    for attempt in range(attempts):
        try:
            return fn()
        except TransientError:
            if attempt == attempts - 1:
                raise                      # give up: let the caller decide
            ceiling = min(cap, base * 2 ** attempt)
            time.sleep(random.uniform(0, ceiling))   # full jitter
Retrying a transient failure with capped exponential backoff and full jitter (Brooker, 2015). Only idempotent operations, or those carrying an idempotency key, should be retried.

Retry storms and retry budgets

Retries multiply across layers. Suppose a tap in the mobile app passes through the API gateway, the order service and the payment client, and each of the three makes up to three attempts. If the payment provider is down, one user action produces 3 × 3 × 3 = 27 calls to it, and an outage that should have meant a steady error rate becomes a retry storm of 27 times the normal load, which keeps the provider down even after the original cause is fixed. Users tapping again add a fourth layer.

Three defences work together. First, retry at one layer only, usually the one closest to the failing dependency, and have other layers pass the error up; Google's SRE book recommends that a server which is overloaded say so explicitly, so that callers above do not retry. Second, use a retry budget: the SRE book describes allowing retries only while they are below 10% of a client's requests, which caps the extra load from retries at about 1.1 times normal instead of up to three times. AWS SDKs implement a similar quota of retry tokens, and gRPC's retry policy includes throttling for the same purpose. Third, combine retries with a circuit breaker, so that a dependency known to be down is not retried at all.

Circuit breakers

A circuit breaker, named by Nygard after the electrical device, wraps calls to a dependency and watches their outcomes. While the dependency is healthy, the breaker is closed and calls pass through, while failures and timeouts are counted. When failures cross a threshold, for example half of the last 20 calls, the breaker opens: for a set period every call fails immediately without touching the network, which protects the caller's threads and gives the dependency room to recover. When the period expires the breaker becomes half-open and lets a few trial calls through. If they succeed it closes; if one fails it opens again for another period. Martin Fowler's 2014 description made the pattern widely known, and Netflix's Hystrix library (now in maintenance mode) popularised it in microservices; Resilience4j for Java and Polly for .NET are the common libraries today, and service meshes offer related protections such as concurrency limits and outlier detection (in Envoy these are called circuit breaking, although they work differently).

The circuit breaker state machine

Circuit breaker states: closed passes calls and counts failures; too many failures open it so calls fail fast; after a wait it becomes half-open and lets trial calls through, closing on success or reopening on failureClosedcalls pass through;failures countedOpencalls fail fast;no remote callHalf-opena few trial callsallowed throughfailure rate over threshold(e.g. 50% of the last 20 calls)after the wait(e.g. 30 s)a trial call failstrial callssucceed
Closed counts failures, open fails fast, and half-open tests recovery with a few trial calls.

The settings deserve thought. What counts as a failure? Timeouts and 5xx responses should; 4xx responses usually should not, because they reflect the caller's request rather than the dependency's health. Slow successes can also count, so that a dependency that answers in 5 seconds is treated as failing. The window must be large enough to avoid tripping on a couple of unlucky calls (libraries require a minimum number of calls) and the open period long enough for recovery but short enough not to prolong an outage. Breakers should be per dependency, and sometimes per endpoint, so that a failing reporting endpoint does not cut off ordering. Retries should treat an open breaker as a final answer rather than something to retry. And every change of state should be logged and graphed, because an open breaker is a symptom someone needs to see.

import time

class CircuitOpen(Exception):
    pass

class CircuitBreaker:
    def __init__(self, failure_threshold=5, reset_after=30.0):
        self.failure_threshold = failure_threshold  # failures in a row that trip it
        self.reset_after = reset_after              # seconds to stay open
        self.failures, self.state, self.opened_at = 0, "closed", 0.0

    def call(self, fn, *args, **kwargs):
        if self.state == "open":
            if time.monotonic() - self.opened_at < self.reset_after:
                raise CircuitOpen("failing fast")   # no remote call at all
            self.state = "half-open"                # let a trial call through
        try:
            result = fn(*args, **kwargs)
        except Exception:
            self.failures += 1
            if self.state == "half-open" or self.failures >= self.failure_threshold:
                self.state, self.opened_at = "open", time.monotonic()
            raise
        self.failures, self.state = 0, "closed"    # success closes the circuit
        return result

payments = CircuitBreaker()
# payments.call(provider.authorise, order_id, amount, timeout=2.0)
A minimal circuit breaker. Production libraries such as Resilience4j (Java) and Polly (.NET) add failure-rate windows, slow-call detection, limits on concurrent trial calls, thread safety and metrics.

Bulkheads

A ship's hull is divided by bulkheads into watertight compartments, so that a breach floods one compartment rather than sinking the ship. In software a bulkhead partitions resources so that one failing or overloaded part cannot consume everything. The commonest form gives each dependency its own bounded thread pool, connection pool or semaphore: payments may use at most 40 concurrent calls per instance, restaurant lookups 40, courier assignment 40. When payments slow down, their pool fills and further payment calls are rejected at once, but restaurant and courier calls carry on. A timeout limits how long each call can hold resources; a bulkhead limits how many can be held at once, so it protects even when timeouts are set too long.

Bulkheads also work at larger scales. Separate deployments of the same service for different callers (one pool of order-service instances for the consumer app, another for the restaurant-partner API) stop a misbehaving partner integration from starving customers. Cell-based architectures, the Z axis of the scale cube, make each cell a bulkhead for a slice of customers, and shuffle sharding, described by AWS engineers, assigns each customer a random small combination of instances so that one customer's poison traffic affects very few others. The cost of every bulkhead is utilisation: capacity reserved for one compartment cannot be lent to another, so pools must be sized from measured demand.

Bulkheads inside one service

Bulkheads: the order service gives each dependency its own small thread pool, so a slow payment provider fills only the payment pool while restaurant and courier calls continueRequestsOrder servicepayment pool: fullrestaurant poolcourier poolPaymentsslowRestaurantshealthyCouriershealthy
With a separate pool per dependency, a slow payment provider fills only the payment pool; restaurant and courier calls continue normally.

Fallbacks and graceful degradation

When a call fails or a breaker is open, the caller must still respond. A fallback decides what it returns. The options, from best to worst for the user, are: a cached or stale value (the last known menu, with prices confirmed at checkout); a sensible default (an estimated delivery time of 30 to 45 minutes instead of a live estimate); a reduced feature (popular dishes instead of personalised recommendations); deferring the work (accepting a review and posting it when the review service recovers); and a clear, fast error that tells the user what to do. Designing these per feature, with the product owner, is what graceful degradation means in practice: the core journey (browse, order, pay, track) keeps working while non-essential features step aside. The same thinking guides load shedding (Section 9.3): know in advance which features to switch off first.

Security decisions are the exception: if the authorisation service is unreachable, the safe fallback is to deny (fail closed), not to allow. Practitioners also disagree about fallbacks in general. Nygard and Netflix's engineers advocate them widely. Engineers at Amazon have argued, in the Builders' Library article Avoiding fallback in distributed systems, that fallback paths are rarely exercised, hard to test, and can themselves trigger outages by switching a large fleet to an untested mode at the worst moment; they prefer making the primary path more reliable and failing fast. Both positions agree on the practical rule: a fallback must be simpler than the thing it replaces, must not depend on the component that failed, and must be exercised regularly, for example in game days (Section 9.8), or it should not exist.

Idempotency: what makes retries and hedges safe

Idempotency is the property that doing something twice has the same effect as doing it once. Every pattern in this section that repeats work depends on it: retries after a timeout, hedged requests, message redelivery after a consumer crash, and replays from a dead-letter queue. Section 6.5 showed how an idempotency key turns POST /orders into a safe retry, and Section 6.4 how idempotent consumers deduplicate messages. The architectural rule is simple: before adding a retry, check that the operation is idempotent, and if it is not, add a key or a duplicate check first. A retry policy on a non-idempotent payment call is not a resilience pattern; it is a double-charging generator.

Hedged requests

Retries deal with failures; hedged requests deal with slowness. Section 2.4 introduced them from Dean and Barroso's The Tail at Scale (2013): send a request to one replica, and if it has not answered within, say, the 95th-percentile latency, send a copy to a second replica and use whichever reply comes first, cancelling the other. Because only the slowest few per cent of requests are duplicated, the extra load is small, while the tail shrinks dramatically, as the benchmark quoted in Section 2.4 showed.

Hedging has preconditions. The operation must be idempotent, normally a read. There must be independent replicas, so that the second copy is unlikely to be slow for the same reason. The extra requests must be budgeted and switched off under overload, because when every replica is slow, hedging doubles the load on a system that is already struggling, exactly like an unbudgeted retry. Used carefully on read-heavy, fan-out paths such as search and menu lookups, it is one of the most effective tail-latency tools available.

The patterns at a glance

PatternProtects againstMain cost or risk
TimeoutWaiting indefinitely; resources held by dead callsToo short fails healthy slow calls; too long protects nothing
Deadline propagationWork continuing after the caller has given upNeeds support in every service and library on the path
Retry with backoff and jitterTransient failuresExtra load; duplicates unless idempotent
Retry budgetRetry storms multiplying loadSome transient failures are not retried during bad periods
Circuit breakerRepeatedly calling a dependency that is downTuning; may cut off a dependency that is only partly failing
BulkheadOne dependency or caller consuming all resourcesReserved capacity cannot be shared
Fallback and degradationTotal failure of a featureUntested code paths; stale or approximate results
Hedged requestTail latency from slow replicasExtra load; reads only

Checklist: every remote call

Review each integration point, including databases, caches, brokers and third-party APIs, against this list.

  • Connect and request timeouts are set explicitlyChosen from the dependency's measured high percentiles and the caller's own deadline, never left at a library default.
  • The remaining deadline is propagatedDownstream calls receive the time left, and work stops when it runs out.
  • Retries are limited and safeOnly transient errors, only idempotent operations or those with an idempotency key, at one layer, with capped exponential backoff and jitter.
  • A retry budget caps the extra loadRetries stay a small fraction of traffic (for example 10%), so an outage cannot multiply load.
  • A circuit breaker guards the dependencyThresholds, wait time and what counts as a failure are tuned and its state is exported as a metric.
  • Concurrency to the dependency is boundedA bulkhead (dedicated pool or semaphore) stops this dependency consuming every thread or connection.
  • Failure behaviour is decidedA fallback, a degraded response or a clear error, agreed with the product owner, and fail-closed for security decisions.
  • Timeouts, retries, breaker trips and fallbacks are observableCounted, graphed and alerted on, so silent degradation is noticed (Section 9.6).
  • The failure path is testedFault injection or a game day shows that the timeouts, breaker and fallback actually work (Section 9.8).

Check your understanding

A service receives 50 requests per second, each calling a dependency with a 4-second timeout. If the dependency hangs, how many threads can these calls tie up?

By Little's law about 50 × 4 = 200 threads, which is why the timeout and a bulkhead limit must fit the pool size.

Why is full jitter better than plain exponential backoff when many clients fail at once?

Plain backoff makes all clients retry at the same moments, in synchronised waves; random delays spread the retries out so the dependency sees a smooth, lower load.

In which state does a circuit breaker make no remote calls, and what ends that state?

In the open state; when the wait period expires it becomes half-open and lets trial calls through.

Why should an authorisation check not fall back to allowing access when the authorisation service is down?

Security decisions must fail closed: allowing access during an outage would let anyone in whenever the service failed or was attacked.

Next · 9.5SLOs and error budgetsHow to measure reliability from the user's point of view with SLIs, set SLO targets, turn them into error budgets and policies, alert on burn rate and keep SLAs safely looser than internal objectives.Continue →

Sources and further reading

Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.

Ask: “Our checkout calls three external services synchronously. What timeouts, retries and circuit breaker settings should we start with, and how do we stop a retry storm?”