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 captionsListen 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
- Cascading failures happen when callers wait on a slow dependency until their own threads and connections run out; every integration point needs deliberate protection (Nygard, Release It!).
- Set explicit connect and request timeouts from measured latency percentiles, shrinking down the call chain, and propagate the remaining deadline so no service works on an abandoned request.
- Retry only transient errors on idempotent operations, a few times, with capped exponential backoff and full jitter (Brooker, 2015).
- Retries multiply across layers (three attempts at three layers gives 27 calls); retry at one layer and cap retries with a budget such as 10% of requests.
- A circuit breaker fails fast while a dependency is down, moving from closed to open on failures and through half-open trial calls back to closed.
- Bulkheads give each dependency or caller its own bounded pool, and fallbacks keep the core journey working, but fallbacks must be simple, independent of the failed part, tested and fail-closed for security.
- Idempotency makes retries, hedges and replays safe; hedged requests cut tail latency for idempotent reads at a small, budgeted cost in extra load.
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
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:31 | Every 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 |
| Consequence | Order 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 upstream | The 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 timeout | Checkout 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 breaker | Once, 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 bulkhead | Limiting payment calls to 40 threads per instance (240 in total) guarantees at least 960 threads for everything else, whatever timeouts are set |
| Adding a fallback | Checkout 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
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 jitterRetry 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
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)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
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
| Pattern | Protects against | Main cost or risk |
|---|---|---|
| Timeout | Waiting indefinitely; resources held by dead calls | Too short fails healthy slow calls; too long protects nothing |
| Deadline propagation | Work continuing after the caller has given up | Needs support in every service and library on the path |
| Retry with backoff and jitter | Transient failures | Extra load; duplicates unless idempotent |
| Retry budget | Retry storms multiplying load | Some transient failures are not retried during bad periods |
| Circuit breaker | Repeatedly calling a dependency that is down | Tuning; may cut off a dependency that is only partly failing |
| Bulkhead | One dependency or caller consuming all resources | Reserved capacity cannot be shared |
| Fallback and degradation | Total failure of a feature | Untested code paths; stale or approximate results |
| Hedged request | Tail latency from slow replicas | Extra 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.
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?”