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
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
Requests pass through normally. Failures tracked.
Failures
0 / 5
Reopen in
—
Service
Click "down/healthy" above to toggle service state
The three states
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
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
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 unaffectedLoad 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.
| Feature | Degraded behavior | Justification |
|---|---|---|
| Recommendation engine | Return popular items from cache | Non-critical. User still sees products. Revenue impact minimal. |
| Personalization service | Show generic content | Non-critical. User experience slightly worse, but page loads. |
| Review/ratings service | Hide review section with 'unavailable' message | Acceptable short-term degradation vs blank page or error. |
| Analytics tracking | Drop events (fire and forget) | Loss of analytics data is acceptable. Do not fail requests for telemetry. |
| Search service | Return empty results with search hint | Core to some flows, non-critical to others. Degrade search, not checkout. |
| Payment service | Do NOT degrade, surface the error | Financial 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).