FjellSystem Design Lab
Lesson 8.2• 10 min read

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 100100 worker threads that calls several downstream services and holds a thread for each call in flight.

Suppose one non-critical dependency, Recommendations, slows from 20 ms20\text{ ms} to 4,000 ms4{,}000\text{ ms}. By Little's law (chapter 1), the threads busy with it equal the call rate times the call duration. So at 4 s4\text{ s} per call, just 100/4=25100 / 4 = 25 calls per second to Recommendations hold all 100100 threads. At 20 ms20\text{ ms}, the same threads could serve 5,0005{,}000 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:

  1. Closed: calls pass through, and outcomes are recorded in a sliding window. In the default count-based window, that is the last 100100 calls. No failure rate is computed until a minimum number of calls (100100 by default) is recorded. When the failure rate reaches the threshold (50%50\% by default), the breaker opens.
  2. 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 (60 s60\text{ s} by default).
  3. Half-open: a limited number of trial calls (1010 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:

Load=λbase×(1+MaxRetries)\text{Load} = \lambda_{\text{base}} \times (1 + \text{MaxRetries})

So 8,000 RPS8{,}000\text{ RPS} with MaxRetries=3\text{MaxRetries} = 3 becomes 32,000 RPS32{,}000\text{ RPS} 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 35=2433^5 = 243 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:

tsleep=random(0, min⁡(M, B⋅2i))t_{\text{sleep}} = \text{random}\left(0,\ \min(M,\ B \cdot 2^i)\right)

Here BB is the base delay, MM the cap and ii the retry number. With the example values B=100 msB = 100\text{ ms} and M=10 sM = 10\text{ s}, 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.

Breaker state
Closed
Failure rate, last 0 of 10 calls
0%
Downstream calls per request
0.00×
Requests failed / rejected
0 / 0 of 0
Retries (up to 3)
Recent requests
RequestOutcomeDownstream callsSleeps before retries
Closed. The breaker judges the last 10 downstream calls.
Figure 2: Circuit Breaker and Bulkhead isolation protecting upstream orchestrators from downstream inventory service latency spikes.
  • 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

Adapted from The System Design Primer by Donne Martin, licensed under CC BY 4.0. Changes were made.