Circuit breakers, retries and bulkheads
Preventing cascading failure storms, 3-state circuit breakers, exponential backoff with full jitter, and thread pool isolation.
What you will learn
- Use Little's law to see how one slow dependency starves a caller's threads.
- Follow a circuit breaker through its closed, open and half-open states.
- Stop retry storms with retry limits and jitter, and contain failures with bulkheads.
Cascading failure dynamics and thread pool starvation
In a call tree, failure travels upward. Consider an orchestrator with worker threads that calls several downstream services and holds a thread for each call in flight.
Suppose one non-critical dependency, Recommendations, slows from to . By Little's law (chapter 1), the threads busy with it equal the call rate times the call duration. So at per call, just calls per second to Recommendations hold all threads. At , the same threads could serve calls per second.
Once every thread is waiting on Recommendations, checkout and payment requests have no thread to run on either. Fowler puts it this way: with many callers on an unresponsive supplier "you can run out of critical resources leading to cascading failures across multiple systems."
The circuit breaker (three states)
Fowler: "You wrap a protected function call in a circuit breaker object, which monitors for failures."
failure rate ≥ threshold
┌────────┐ ───────────────────────> ┌────────┐
│ CLOSED │ │ OPEN │ calls rejected
└────────┘ <──────┐ ┌─────── └────────┘ without a network call
│ │ trial calls │
trial calls │ │ fail too often │ wait duration
mostly succeed│ │ │ elapses
│ ┌───────────┐ <──────────┘
└── │ HALF-OPEN │
└───────────┘
Exact behavior varies by library. The numbers below are resilience4j's documented defaults:
- Closed: calls pass through, and outcomes are recorded in a sliding window. In the default count-based window, that is the last calls. No failure rate is computed until a minimum number of calls ( by default) is recorded. When the failure rate reaches the threshold ( by default), the breaker opens.
- Open: calls are rejected at once with a
CallNotPermittedException, so the caller can serve a fallback without waiting on the network. The breaker stays open for a configured wait ( by default). - Half-open: a limited number of trial calls ( by default) are let through. If their failure rate is still at or above the threshold, the breaker opens again; otherwise it closes. Fowler's original description uses a single trial call.
The AWS Builders' Library adds a caution: circuit breakers "introduce modal behavior into systems that can be difficult to test".
Retry storms and backoff with jitter
When calls fail because the service is overloaded, "retries can make the overload worse instead of better" (AWS Builders' Library). If every call fails, each layer that retries multiplies the load:
So with becomes at the failing service. Retries at several layers multiply too. AWS's example is a five-layer stack where each layer tries three times: the database sees times the load, "making it unlikely to ever recover". Retry only at one layer, and cap retries, for example with a token bucket that allows retries only while tokens remain.
Full jitter (Marc Brooker's AWS post, chapter 7) spreads the retries that remain:
Here is the base delay, the cap and the retry number. With the example values and , the ceiling doubles 100, 200, 400 … ms until it reaches 10 s at the eighth retry. Clients that all failed at the same moment then retry at different moments instead of in lockstep.
Bulkheads
The pattern is named after the sectioned partitions of a ship's hull: "if the hull of a ship is compromised, only the damaged section fills with water" (Azure Architecture Center). A consumer gives each dependency its own pool, such as a connection pool, thread pool or semaphore limit. Then "if a service begins to fail, it only affects the connection pool assigned for that service." In the example above, a separate pool for Recommendations would leave checkout's threads free.
The price is less efficient use of resources. Azure suggests combining bulkheads with retries, circuit breakers and throttling.
Circuit breaker and retries
Closed, open and half-open transitions over a count-based window, and how retries multiply downstream calls.
| Request | Outcome | Downstream calls | Sleeps before retries |
|---|
- API Consumers: 1 × Clients & Users
- API Gateway: 2 × Application Load Balancer
- Checkout Orchestrator: 4 × App Compute Server
- Resilience4j Circuit Breaker: 2 × Redis In-Memory Cache
- Degraded Inventory Service: 2 × App Compute Server
- Stale Fallback Inventory Cache: 2 × Distributed NoSQL Store
- API Consumers sends requests to API Gateway
- API Gateway sends requests to Checkout Orchestrator
- Checkout Orchestrator sends requests to Resilience4j Circuit Breaker
- Resilience4j Circuit Breaker sends requests to Degraded Inventory Service
- Resilience4j Circuit Breaker sends requests to Stale Fallback Inventory Cache
Key takeaways
- Threads tied up equal call rate × call duration, so a dependency that slows to 4 s can take every thread and stop unrelated requests.
- A circuit breaker counts failures, rejects calls at once while open so the caller can fall back, and lets trial calls through when half-open.
- Every layer that retries multiplies the load on a failing service; limit retries and spread them with full jitter.
- Bulkheads give each dependency its own pool, so one failure fills only its own section.
Sources
- System Design Primer: Remote procedure call (RPC)
- Martin Fowler: CircuitBreaker
- resilience4j: CircuitBreaker
- AWS Builders’ Library: Timeouts, retries, and backoff with jitter
- Marc Brooker: Exponential Backoff And Jitter (AWS Architecture Blog)
- Azure Architecture Center: Bulkhead pattern
Adapted from The System Design Primer by Donne Martin, licensed under CC BY 4.0. Changes were made.