Codetail

Article 14 of 15

Resilience Patterns

Design for failure. Build systems that bend, not break.

20 min read

How Distributed Systems Fail

Distributed systems do not fail cleanly. In a monolith, a crash is a crash: the process dies and the error is immediate. In a distributed system, failures are partial, probabilistic, and often invisible. A service can be running while returning garbage. A database can be up while timing out on every write. A network can be connected while dropping 10% of packets.

Resilience engineering is about designing for failures that you cannot prevent. Hardware fails, networks partition, dependencies time out, bugs are deployed. The goal is not to eliminate these events, it is to bound their impact: contain the failure domain, degrade gracefully under load, recover automatically without human intervention.

Failure taxonomy

Crash failureLow

A service stops responding entirely. The process dies, the container exits, the node goes dark. The caller gets a connection refused or a timeout with no response.

Detection: Health checks fail. Load balancer stops routing. Kubernetes restarts the pod.

Slow failure (gray failure)High

The service responds, but slowly. Requests take 3 seconds instead of 100ms. The caller's thread pool exhausts waiting. Upstream callers queue up. The system appears running but cannot serve load.

Detection: Latency percentiles (p99) spike. Connection pools saturate. Upstream timeout errors increase.

Partial failureHigh

Some requests succeed, some fail. One database replica is degraded. One microservice instance returns 500s. The failure is non-deterministic from the caller's perspective.

Detection: Error rate increases (not to 100%). Requires per-instance monitoring. Log sampling can miss it.

Cascading failureCritical

Service A slows down. Service B, which calls A, backs up. Service C, which calls B, backs up. Thread pools fill. The entire call chain fails. One slow database brings down the whole system.

Detection: Correlated latency and error spikes across services. Distributed trace shows the originating bottleneck.

Thundering herdHigh

A cache expires or a service restarts. Every client that was waiting retries simultaneously. The newly recovered service receives 10x its normal load and falls over immediately.

Detection: Traffic spike precisely when a service recovers. Cache miss rate spikes to 100% for one interval.

The fallacies of distributed computing

Peter Deutsch and James Gosling catalogued the eight assumptions engineers make about networks that are always false in production. Each false assumption is a category of failure that resilience patterns must address.

Fallacy: The network is reliable

Packets drop. Links fail. Routers restart. TCP handles retransmission, but application-level retries are still needed.

Fallacy: Latency is zero

Cross-datacenter calls add 50-150ms. Cross-continent: 150-300ms. Timeouts must be set lower than the caller's patience.

Fallacy: Bandwidth is infinite

Serializing large objects for network calls is expensive. Payload size matters at scale.

Fallacy: The network is secure

Traffic must be encrypted (TLS). Internal service-to-service calls are not inherently safe.

Fallacy: Topology doesn't change

Services scale, move, and restart. Service discovery must be dynamic, not hardcoded IPs.

Fallacy: There is one administrator

Multiple teams, multiple services, independent deployments. No single party controls the full system.

Fallacy: Transport cost is zero

Network I/O is expensive in CPU cycles and latency compared to in-process calls.

Fallacy: The network is homogeneous

Different services use different protocols, encodings, and versioning. Integration is never uniform.

Retries and Timeouts

Timeouts and retries are the first line of resilience: every outbound call must have a timeout, and transient failures should be retried with backoff. Without timeouts, a slow dependency occupies a thread forever. Without retries, a single dropped packet causes a user-facing error. Without backoff, retries amplify load on an already struggling service.

Timeout types

Connection timeout1-3 seconds

How long to wait for the TCP connection to be established. If the server is unreachable or the load balancer is saturated, this fires first.

When: Always set. A missing connection timeout means waiting indefinitely for a dead server.

Read timeout500ms - 5 seconds

How long to wait after the connection is established for the server to send a response byte. Triggers when the server is connected but hanging on a slow query or lock.

When: Set based on the p99 latency of the downstream endpoint. Not a global default, tune per endpoint.

Total request timeoutp99 + buffer

Hard cap on the entire request lifecycle including retries. Prevents a retry loop from occupying a thread longer than the caller's SLA allows.

When: Set to (retry_count * (read_timeout + jitter)) + buffer. Never let retries outlive the caller's patience.

Idle connection timeout30-90 seconds

How long to keep an idle connection in the pool before closing it. Must be shorter than any upstream load balancer or NAT gateway idle timeout to avoid surprise RST packets.

When: Connection pool maintenance. Default in most HTTP clients. Check against your infrastructure's idle timeout.

Retry strategies

Only retry idempotent operations. A GET request can always be retried. A POST that creates a record must not be retried without an idempotency key, you risk creating the record twice. An idempotency key (a UUID in a request header) lets the server detect and discard duplicate requests, making any operation safe to retry.

Exponential backoffwait = base * 2^attempt
1s2s4s8s16s

Each retry waits twice as long as the previous. Reduces retry storm as all clients slow down together. Without jitter, clients that started together retry together.

Exponential backoff with jitterwait = random(0, base * 2^attempt)
0.3s1.7s3.1s9.4s

Randomizes the wait time. Clients that started together now retry at different times, spreading load on the recovering service. Full jitter (0 to max) is most effective.

Decorrelated jitterwait = random(base, prev_wait * 3)
1s2.3s5.8s14.2s

AWS's recommended approach. Each retry is a random value between the base delay and 3x the previous delay. Produces better spread than exponential + full jitter.

Retry with exponential backoff + jitter + idempotency key

import asyncio, random, uuid, httpx

async def call_with_retry(
    url: str,
    payload: dict,
    max_attempts: int = 3,
    base_delay: float = 0.5,
) -> dict:
    idempotency_key = str(uuid.uuid4())

    for attempt in range(max_attempts):
        try:
            async with httpx.AsyncClient(timeout=3.0) as client:
                resp = await client.post(
                    url,
                    json=payload,
                    headers={"Idempotency-Key": idempotency_key},
                )
                resp.raise_for_status()
                return resp.json()

        except (httpx.TimeoutException, httpx.HTTPStatusError) as e:
            if attempt == max_attempts - 1:
                raise
            # Exponential backoff with full jitter
            max_wait = base_delay * (2 ** attempt)
            wait = random.uniform(0, max_wait)
            await asyncio.sleep(wait)

    raise RuntimeError("unreachable")

What NOT to retry

!

4xx responses (except 429)

400 Bad Request, 401, 403, 404 indicate a client error that will not resolve on retry. Retrying wastes resources and delays the error surfacing to the caller.

!

Non-idempotent operations without idempotency keys

POST /orders without an idempotency key will create duplicate orders on retry. Add Idempotency-Key header and handle deduplication on the server.

!

When the circuit is open

Do not retry into an open circuit. The circuit is open because the service is failing. Retrying bypasses the protection and defeats the purpose.

!

When the total timeout budget is exhausted

If the caller has a 2-second SLA and two retries already took 1.8 seconds, the third retry is not useful, it will time out from the caller's perspective regardless.

Circuit Breaker

The circuit breaker pattern prevents cascading failures by stopping calls to a service that is known to be failing. Named after the electrical breaker that interrupts current when a fault is detected, it implements a state machine with three states: Closed (normal operation), Open (fail-fast, no calls), and Half-Open (probe to test recovery).

The key insight is fail-fast under failure. Without a circuit breaker, a slow service causes the caller to queue up waiting threads until its thread pool exhausts. With a circuit breaker open, the caller returns an error immediately without waiting. This prevents the failure from propagating upstream and frees threads to serve other requests.

Circuit Breaker State Machine
CLOSED

Requests pass through normally. Failures tracked.

closed
open
half- open

Failures

0 / 5

Reopen in

Service

Click "down/healthy" above to toggle service state

The three states

CLOSED

Normal operation. All requests pass through to the service. The circuit breaker monitors the failure rate. When consecutive failures reach the threshold, the circuit trips to Open.

Failure count >= threshold -> OPEN

OPEN

Fail-fast. All requests are rejected immediately without calling the service. The caller receives an error immediately instead of waiting for a timeout. After a configured timeout, the circuit transitions to Half-Open to test recovery.

Timeout elapsed -> HALF-OPEN

HALF-OPEN

Probe. One request is allowed through to test whether the service has recovered. If it succeeds, the circuit resets to Closed and normal operation resumes. If it fails, the circuit returns to Open and the timeout resets.

Probe success -> CLOSED | Probe failure -> OPEN

Implementation with a fallback

Python circuit breaker with graceful degradation

from circuitbreaker import circuit

@circuit(
    failure_threshold=5,     # trip after 5 consecutive failures
    recovery_timeout=30,     # wait 30s before half-open probe
    expected_exception=Exception,
)
async def get_recommendations(user_id: str) -> list[str]:
    return await recommendations_service.get(user_id)

async def handler(user_id: str):
    try:
        recs = await get_recommendations(user_id)
        return recs
    except CircuitBreakerOpen:
        # Circuit is open, return cached or static fallback
        # Do NOT let this error propagate to the user
        return get_fallback_recommendations()
    except Exception as e:
        # Service error, circuit will count this failure
        log.warning("recommendations failed", error=str(e))
        return get_fallback_recommendations()

def get_fallback_recommendations() -> list[str]:
    # Return popular items, recently viewed, or empty list
    # Never fail the entire request because of a non-critical service
    return popular_items_cache.get() or []

What triggers a circuit breaker

Consecutive failures

N failures in a row. Simple and predictable. Sensitive to transient single failures.

Failure rate in a window

X% of requests in the last N seconds fail. Less sensitive to transient spikes. Requires more state.

Slow calls

Calls taking longer than T ms count as failures. Prevents slow degradation from exhausting thread pools.

Exception types

Only count specific exceptions (network errors, 5xx). Ignore 4xx, those are client errors, not service failures.

Bulkheads, Load Shedding, and Graceful Degradation

Bulkhead pattern

A bulkhead is a partition in a ship's hull that isolates flooding to one compartment. In software, it is resource isolation: separate thread pools, connection pools, or process limits for different dependencies so that one failing dependency cannot exhaust resources shared with healthy ones.

Without bulkheads, a slow payments service consumes all available threads in the application's shared thread pool. New requests for any endpoint, including the fast, healthy ones, queue and eventually time out. The cascade is horizontal: one failing dependency brings down everything.

Thread pool bulkhead, separate pools per downstream service

from concurrent.futures import ThreadPoolExecutor

# Isolated pools per dependency, one pool exhausting
# does not affect the others
POOLS = {
    "payments":        ThreadPoolExecutor(max_workers=10),
    "recommendations": ThreadPoolExecutor(max_workers=5),
    "notifications":   ThreadPoolExecutor(max_workers=3),
}

async def call_payments(payload):
    loop = asyncio.get_event_loop()
    try:
        return await asyncio.wait_for(
            loop.run_in_executor(POOLS["payments"], _call_payments, payload),
            timeout=2.0,   # per-pool timeout
        )
    except asyncio.TimeoutError:
        raise PaymentsUnavailableError()

# If recommendations pool is exhausted (5 threads all waiting),
# payments pool (10 threads) is completely unaffected

Load shedding

Load shedding is the deliberate rejection of requests when the system is operating above its sustainable capacity. The alternative, accepting all requests and attempting to serve them all, leads to queuing, growing latency, and eventual collapse. A system that rejects 20% of requests under overload can serve the remaining 80% well. A system that accepts all requests serves 100% of them badly.

Queue depth limit

When the work queue depth exceeds N, return 503 immediately instead of queueing. Downstream consumers see fast failures they can retry, not slow timeouts.

Concurrency limit

Cap the number of in-flight requests to the server. Requests beyond the limit are rejected with 503. Prevents memory from growing without bound under sustained overload.

CPU/memory threshold

When CPU > 85% or memory > 90%, start shedding low-priority requests. Protects the process from OOM kills or thrashing under extreme load.

Priority-based shedding

Assign request priorities (health checks and payments = high; analytics and recommendations = low). Shed low-priority requests first. Critical paths survive overload longer.

Graceful degradation

A resilient system distinguishes between critical and non-critical features. When a non-critical dependency fails, the system continues operating at reduced capability rather than returning an error. When a critical dependency fails, it surfaces the error clearly rather than silently returning wrong data.

FeatureDegraded behaviorJustification
Recommendation engineReturn popular items from cacheNon-critical. User still sees products. Revenue impact minimal.
Personalization serviceShow generic contentNon-critical. User experience slightly worse, but page loads.
Review/ratings serviceHide review section with 'unavailable' messageAcceptable short-term degradation vs blank page or error.
Analytics trackingDrop events (fire and forget)Loss of analytics data is acceptable. Do not fail requests for telemetry.
Search serviceReturn empty results with search hintCore to some flows, non-critical to others. Degrade search, not checkout.
Payment serviceDo NOT degrade, surface the errorFinancial operations must not silently fail. Show error and retry option.

Health checks and readiness

Liveness probe

Is the process alive? Returns 200 if the application is running and not deadlocked. A failing liveness probe causes the container orchestrator to restart the process.

Readiness probe

Is the process ready to accept traffic? Returns 200 only when all dependencies (database, cache, upstream services) are reachable. A failing readiness probe removes the instance from the load balancer without restarting it.

Startup probe

Has the application finished initializing? Prevents the liveness probe from killing a slow-starting application during boot (database migrations, cache warming).

Deep health check

Verifies the full request path: connect to the database, execute a simple query, check cache connectivity. Used by on-call engineers, not by the load balancer (too expensive per-request).