Resilience Patterns
When a downstream service degrades or fails, the naive default β keep sending requests and wait for a response β reliably produces cascading failures. The calling service's threads block, its queue fills, and the failure propagates upstream to clients who had nothing to do with the broken dependency.
Resilience patterns are design decisions that interrupt this propagation. They do not prevent failures; they limit how far a failure travels through the system1.
flowchart LR
client --> gateway
gateway -->|"Timeout\nCircuit Breaker\nBulkhead"| downstream:::slow
gateway -->|"fast failure\nor fallback"| client:::ok
classDef slow fill:#f90,color:#000
classDef ok fill:#6c6,color:#fff The patterns below are complementary β production systems use them in combination, not in isolation. Throughout the page we use one running example from the store: the order service calls the product service (through an OpenFeign client, as in Hands-on 1) to check prices before saving an order.
| Pattern | Question it answers | Protects against |
|---|---|---|
| Timeout | How long am I willing to wait? | Hanging calls |
| Retry | Is it worth trying again? | Transient failures |
| Circuit breaker | Should I even try? | A dependency that is known to be down |
| Fallback | What do I answer instead? | Total loss of a feature |
| Bulkhead | How much of my capacity can one dependency take? | Resource exhaustion spreading |
| Rate limiting | How much traffic do I accept? | Spikes and abusive clients |
| Health checks | Should traffic reach this instance? | Routing to broken instances |
Timeout
A timeout is a deadline applied to an outbound call. If the downstream service does not respond within the deadline, the call is abandoned and an error is returned to the caller immediately.
Without a timeout, a single hanging dependency can hold threads indefinitely. With a timeout, the maximum damage any single slow call can do is bounded.
sequenceDiagram
participant C as Client
participant G as Gateway
participant P as Payment Service
C->>+G: POST /checkout
G->>+P: POST /charge
Note over P: processing...
Note over G: deadline exceeded (500 ms)
G-->>-C: 503 Service Unavailable
P-->>-G: (response arrives late β discarded) Choosing timeout values
- Percentile-based: set the timeout at the P99.9 latency of the downstream service under normal conditions. Anything slower than that is already abnormal.
- Budget-aware: if the upstream call has its own timeout of 1 s, all downstream calls within that request must complete well under 1 s combined.
- Distinct per dependency: a database query, a third-party API, and an internal microservice have very different latency profiles. A single global timeout value is almost always wrong.
There are usually two timeouts on an HTTP call: the connect timeout (how long to wait to open the TCP connection) and the read timeout (how long to wait for the response once connected). Configure both.
Timeouts on the store's Feign client
# order-service application.yaml (Spring Cloud OpenFeign)
spring:
cloud:
openfeign:
client:
config:
product: # the @FeignClient name
connect-timeout: 500 # ms
read-timeout: 1000 # ms
If the product service takes more than one second, the order service gets an exception instead of a blocked thread, and can decide what to do next.
Timeouts are only the first step
A timeout on its own just converts a hang into a fast error. Combine it with a retry policy (below) for transient failures, and with a circuit breaker and a fallback for longer outages.
Retry with Exponential Backoff
A retry re-attempts a failed request after a waiting period. Retries are appropriate for transient failures β brief network blips, momentary service restarts, or transient resource exhaustion β where the same request is likely to succeed on a subsequent attempt.
sequenceDiagram
participant C as Client
participant S as Service
C->>+S: Request (attempt 1)
S-->>-C: 503 Service Unavailable
Note over C: wait 1 s
C->>+S: Request (attempt 2)
S-->>-C: 503 Service Unavailable
Note over C: wait 2 s
C->>+S: Request (attempt 3)
S-->>-C: 200 OK β Exponential backoff
Retrying immediately on failure is dangerous: if many clients all retry simultaneously after a service restart, the resulting spike can overwhelm the recovering service β a thundering herd. Exponential backoff spaces retries out:
| Attempt | Formula | Wait (c = 1 s) |
|---|---|---|
| 1 | \(1 \cdot 2^0\) | 1 s |
| 2 | \(1 \cdot 2^1\) | 2 s |
| 3 | \(1 \cdot 2^2\) | 4 s |
| 4 | \(1 \cdot 2^3\) | 8 s |
| 5 | \(1 \cdot 2^4\), capped | 16 s (= max_wait of 16 s) |
| 6+ | capped at max | 16 s |
Jitter
Even with exponential backoff, clients that all failed at the same moment will retry at the same moments β a synchronised wave of retries. Adding a random jitter breaks this synchronisation:
Marc Brooker's analysis at AWS2 compared several variants and recommends full jitter, which randomises the entire wait instead of adding a small random amount:
With 1,000 clients failing at the same instant, full jitter spreads their retries uniformly over the interval instead of sending 1,000 requests at exactly t = 1 s, 2 s, 4 s.
What to retry β and what not to
| Response | Retry? | Why |
|---|---|---|
| Connection refused, connection reset, timeout | Yes | Typical transient failure |
503 Service Unavailable, 429 Too Many Requests | Yes, with backoff (respect Retry-After if present) | The server is asking you to come back later |
500 Internal Server Error | Maybe, once | Often a bug that will fail again |
400, 401, 403, 404, 422 | No | The request itself is wrong; retrying cannot fix it |
Retry amplification
Retries multiply load exactly when the system is weakest. If the gateway, the order service and the product client each retry 3 times, one user click can become 3 Γ 3 Γ 3 = 27 calls to a struggling product service. Retry at one layer only (usually the one closest to the failing dependency), cap the number of attempts, and stop retrying when the circuit breaker is open.
Do not retry non-idempotent operations
Retrying a POST /orders that creates a new order may create duplicates. Only retry operations that are idempotent (GET, PUT, DELETE) or that are explicitly designed for retry safety (idempotency keys, deduplication tokens).
An operation is idempotent when doing it twice has the same effect as doing it once. The common way to make a POST safe to retry is an idempotency key: the client generates a unique ID (e.g. a UUID) per logical operation and sends it in a header; the server stores the keys it has already processed and, on a repeated key, returns the stored result instead of creating a second order.
sequenceDiagram
participant C as Client
participant O as Order service
participant DB as Postgres
C->>O: POST /orders (Idempotency-Key: k-42)
O->>DB: key k-42 seen? no β insert order + key
O--xC: 201 Created (response lost)
C->>O: retry POST /orders (Idempotency-Key: k-42)
O->>DB: key k-42 seen? yes
O-->>C: 201 Created (same order, no duplicate) Circuit Breaker
The circuit breaker3 is named by analogy with the electrical component: when the current load exceeds safe limits, the breaker trips and interrupts the circuit. In software, when a downstream service failure rate exceeds a threshold, the circuit breaker opens and immediately rejects calls to that service β without attempting the network call at all.
This prevents the calling service from wasting threads and connections on a dependency that is known to be unavailable.
stateDiagram-v2
[*] --> Closed
Closed --> Open : failure rate exceeds threshold\n(e.g. > 50% errors in last 10 calls)
Open --> HalfOpen : wait timeout expires\n(e.g. 30 seconds)
HalfOpen --> Closed : probe request succeeds
HalfOpen --> Open : probe request fails Closed (normal operation) : Requests pass through. The breaker counts successes and failures. If the failure rate crosses the configured threshold, the breaker trips to Open.
Open (failing fast) : All requests are rejected immediately with an error β no network call is made. This protects the caller's resources and gives the downstream service time to recover. After a configured wait timeout, the breaker moves to Half-Open.
Half-Open (testing recovery) : A limited number of probe requests are allowed through. If they succeed, the downstream service has recovered and the breaker closes. If they fail, the breaker returns to Open.
Configuration knobs
| Parameter | Typical value | Effect |
|---|---|---|
| Failure threshold | 50% of last 10β20 calls | Sensitivity to failures |
| Wait timeout | 30β60 seconds | How long the service gets to recover |
| Half-Open probe count | 3β5 requests | How cautiously to test recovery |
| Slow call threshold | 2Γ normal P99 | Treat slow calls as failures |
Spring Boot: the Resilience4j library4 implements all of these patterns with @CircuitBreaker, @Retry, @Bulkhead, @RateLimiter and @TimeLimiter annotations (dependency io.github.resilience4j:resilience4j-spring-boot3 plus spring-boot-starter-aop).
Circuit breaker and retry around the product call
@CircuitBreaker(name = "product", fallbackMethod = "priceFallback")
@Retry(name = "product")
public ProductOut findProduct(String id) {
return productController.findById(id).getBody(); // Feign call
}
// Same signature + the exception. Called when the call fails
// or when the breaker is open (CallNotPermittedException).
private ProductOut priceFallback(String id, Throwable t) {
throw new ResponseStatusException(
HttpStatus.SERVICE_UNAVAILABLE, "catalogue unavailable, try again later");
}
resilience4j:
circuitbreaker:
instances:
product:
sliding-window-size: 10 # last 10 calls
failure-rate-threshold: 50 # % β Open
slow-call-duration-threshold: 2s # slow = failure
wait-duration-in-open-state: 30s # Open β Half-Open
permitted-number-of-calls-in-half-open-state: 3
retry:
instances:
product:
max-attempts: 3 # 1 call + 2 retries
wait-duration: 500ms
enable-exponential-backoff: true # 500 ms, 1 s, ...
exponential-backoff-multiplier: 2
The breaker's state is exported as a metric (resilience4j_circuitbreaker_state) through Actuator, so you can graph it in Grafana (Hands-on 6).
The simulation below shows the circuit breaker cycling through all three states as the failure rate changes:
Fallback and graceful degradation
Failing fast is better than hanging, but an error is still an error. A fallback is what the caller does instead when a call fails or the breaker is open. The goal is graceful degradation: lose a feature, not the whole page.
| Dependency down | Possible fallback in the store |
|---|---|
| Recommendation service | Show the "best sellers" list cached in Redis (Hands-on 8) |
| Product service, for reads | Serve the last cached product data, marked as possibly stale |
| Notification service | Publish the e-mail request to Kafka and send it later (Event-Driven Architecture) |
| Payment service | No safe fallback: answer 503 quickly with a clear message β never pretend the payment succeeded |
A fallback must be safe and simple
A fallback that calls another remote service can fail too (and needs its own protection). And some operations β charging a card, reserving stock β must not be faked: failing clearly is the correct degraded behaviour.
Bulkhead
The bulkhead pattern β named after watertight compartments in a ship hull β isolates resources by dependency. Rather than sharing a single thread pool across all downstream calls, each dependency gets its own pool.
flowchart TD
gateway --> poolA["Thread Pool A\n(max 10 threads)"]
gateway --> poolB["Thread Pool B\n(max 10 threads)"]
poolA --> payment["Payment Service\n(degraded β οΈ)"]:::slow
poolB --> inventory["Inventory Service\n(healthy β)"]:::ok
classDef slow fill:#f90,color:#000
classDef ok fill:#6c6,color:#fff If Payment Service degrades and fills all 10 threads in Pool A, Pool B is completely unaffected. Inventory Service continues to handle requests at full speed. Without bulkheads, a single slow dependency saturates the shared pool and degrades all other calls equally.
The cost of bulkheads is resource overhead: ten separate pools of 10 threads each use more memory than one pool of 100 threads. The trade-off is almost always worth it for critical-path dependencies.
Resilience4j offers two kinds of bulkhead: a semaphore bulkhead (max-concurrent-calls, limits how many threads may be inside the call at once β cheap, the default) and a thread-pool bulkhead (the call runs in a dedicated pool). The same idea applies outside the JVM: separate database connection pools per workload, or separate Kubernetes deployments for critical and non-critical traffic.
Rate Limiting
Rate limiting caps the number of requests a client (or the system) can make within a time window, protecting services from traffic spikes and abusive clients.
Token bucket algorithm
The token bucket is the most common rate limiting algorithm. It models a bucket that fills at a constant rate with tokens; each request consumes one token; requests that arrive when the bucket is empty are rejected or queued.
flowchart LR
refill["Token source\n(refill rate: 100 req/s)"] -->|adds tokens| bucket["Token Bucket\n(capacity: 200)"]
request["Incoming\nRequest"] -->|consumes 1 token| bucket
bucket -->|tokens available| allowed["β Allowed"]
bucket -->|bucket empty| rejected["β 429 Too Many Requests"] | Property | Effect |
|---|---|
| Refill rate | Steady-state maximum throughput |
| Bucket capacity (burst) | How much instantaneous traffic above the rate is tolerated |
A bucket capacity larger than the refill rate allows short bursts while still enforcing a long-term average. Spring Cloud Gateway's RequestRateLimiter filter (backed by Redis) and AWS API Gateway use a token bucket. Nginx limit_req uses the closely related leaky bucket: requests drain at a fixed rate and the burst parameter sets how many may wait in the queue.
Rate limiting at the store's edge (Nginx)
# 10 requests/s per client IP, bursts of up to 20 queued requests
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
server {
location / {
limit_req zone=api burst=20 nodelay;
limit_req_status 429;
proxy_pass http://gateway;
}
}
This fits in front of the gateway replicas of Hands-on 7. Rate limits per user or per API key (rather than per IP) are usually applied in the gateway, where the authenticated identity is known.
Health Checks
Health check endpoints allow orchestrators (Kubernetes, Docker Compose, load balancers) to detect failed instances and stop routing traffic to them before clients notice.
Two distinct types serve different purposes:
Liveness : Is the process alive? Returns 200 OK if the application is running and not deadlocked. If a liveness check fails, the orchestrator restarts the container.
Readiness : Is the service ready to serve traffic? Returns 200 OK only if the instance has finished starting and can do useful work (e.g. its own database is reachable). If a readiness check fails, the orchestrator removes the instance from the load balancer pool without restarting it.
Startup (Kubernetes) : Has the application finished starting? Liveness and readiness checks are not run until it succeeds, so a slow-starting JVM is not killed during boot.
flowchart LR
lb["Load Balancer"] -->|"GET /actuator/health/readiness"| svc["Service Instance"]
svc -->|200 OK| lb
lb -->|routes traffic| svc
svc2["Service Instance\n(db connection lost)"]
lb -->|"GET /actuator/health/readiness"| svc2
svc2 -->|503 DOWN| lb
lb -->|removed from pool| svc2:::failed
classDef failed fill:#f66,color:#fff Spring Boot Actuator exposes both at /actuator/health/liveness and /actuator/health/readiness. They are enabled automatically when the application detects it runs on Kubernetes; elsewhere (e.g. Docker Compose) set management.endpoint.health.probes.enabled=true. By default they reflect only the application's internal state; add a dependency to readiness explicitly, e.g. management.endpoint.health.group.readiness.include=readinessState,db.
Probes for the order service on Kubernetes
# inside the container spec of the Deployment
startupProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
failureThreshold: 30 # up to 30 Γ 5 s = 150 s to start
periodSeconds: 5
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 5
See Hands-on 5 for the Deployments these probes belong to.
Never put external dependencies in the liveness check
If liveness checks the database and the database blips, Kubernetes restarts every replica at the same time β turning a 10-second database hiccup into a full outage while all JVMs boot again. Liveness should answer only "is this process stuck?". Be careful even with readiness: if every replica depends on the same database, they all become unready together and the service disappears instead of returning clear errors.
Defence in depth
These patterns are layered, not chosen in isolation. A production-grade call chain combines all of them:
flowchart LR
client --> rl["Rate Limiter\n(protect service)"]
rl --> cb["Circuit Breaker\n(fast-fail open deps)"]
cb --> bh["Bulkhead\n(isolate thread pool)"]
bh --> to["Timeout\n(bound wait time)"]
to --> retry["Retry + Backoff\n(handle transients)"]
retry --> downstream[Downstream Service] Each layer handles a different failure scenario:
| Layer | What it handles |
|---|---|
| Rate limiter | Traffic spikes, abusive clients |
| Circuit breaker | Known-failing dependencies |
| Bulkhead | Slow dependencies bleeding into other calls |
| Timeout | Unbounded waiting |
| Retry + backoff | Transient errors |
The order is a design decision
The diagram shows one common conceptual layering. Libraries make their own choice: in Resilience4j's Spring Boot integration the default aspect order is Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( call ) ) ) ) ) β the retry is the outermost layer, so every attempt is counted by the circuit breaker and an open breaker makes the remaining attempts fail immediately. Whatever the order, check that retries do not bypass the breaker and that the sum of timeouts Γ attempts fits in the caller's own deadline.
Connections
- Hands-on 1 β the Feign clients that need timeouts and breakers.
- Hands-on 7 β Nginx in front of the gateway, where edge rate limiting lives.
- Hands-on 9 β retries and dead letter queues for asynchronous consumers.
- SLI / SLO / SLA β the budget these patterns protect.
Key takeaways
- Every remote call needs a timeout (connect + read), chosen per dependency and inside the caller's budget.
- Retry only transient failures and idempotent operations, with exponential backoff and full jitter, at one layer only; use idempotency keys to make
POSTsafe. - A circuit breaker (Closed β Open β Half-Open) fails fast on a dependency known to be down; pair it with a safe fallback.
- Bulkheads keep one slow dependency from consuming all threads or connections; rate limiting (token / leaky bucket) protects the service from excess traffic.
- Liveness restarts, readiness removes from the load balancer; never check external dependencies in liveness.
- The patterns are layered; their order and combined timeouts must be designed, then verified with chaos experiments.
-
NYGARD, M. Release It! Design and Deploy Production-Ready Software, 2nd ed. Pragmatic Programmers, 2018.Β β©
-
BROOKER, M. Exponential Backoff And Jitter. AWS Architecture Blog, 2015.Β β©
-
FOWLER, M. CircuitBreaker. martinfowler.com, 2014.Β β©