Chapter 7: Reliability & Fault Tolerance
The Iron Law of Distributed Systems: Everything fails, all the time. Disks fail, networks partition, processes crash, dependencies time out, and humans make mistakes. The question is not if your system will fail, but when and how gracefully it degrades.
This chapter covers the battle-tested patterns that keep systems running through chaos. You'll learn how to design systems that survive failures, recover automatically, and maintain availability even when components fail.
What You'll Master
- Availability mathematics (99.9% vs 99.99% vs 99.999%)
- The 7 types of failures that kill systems
- When to use Active-Active vs Active-Passive redundancy
- Circuit breakers, retries, bulkheads, and timeouts
- Chaos engineering principles from Netflix
- Disaster recovery strategies (RPO/RTO tradeoffs)
- Health checking patterns for Kubernetes and beyond
7.1 Reliability Fundamentals
Key Metrics
MTBF, MTTR, MTTF
- MTBF (Mean Time Between Failures): Average time between failures for repairable systems. If a server fails every 720 hours on average, MTBF = 720 hours = 30 days.
- MTTR (Mean Time To Repair): Average time to restore service after failure. If average incident takes 2 hours to fix, MTTR = 2 hours.
- MTTF (Mean Time To Failure): Average time to failure for non-repairable components (e.g., hard drives). Once failed, you replace them.
graph LR
A[System Running] -->|MTTF or MTBF| B[Failure Occurs]
B -->|Detection Time| C[Failure Detected]
C -->|MTTR| D[System Restored]
D -->|MTBF| B
style A fill:#2d5016
style B fill:#8b0000
style D fill:#2d5016
Availability: The Nine Game
Availability = (Total Time - Downtime) / Total Time
Or equivalently: Availability = MTBF / (MTBF + MTTR)
| Availability % | Nines | Downtime per Year | Downtime per Month | Downtime per Week |
|---|---|---|---|---|
| 90% | 1 nine | 36.53 days | 73 hours | 16.8 hours |
| 99% | 2 nines | 3.65 days | 7.3 hours | 1.68 hours |
| 99.9% | 3 nines | 8.76 hours | 43.8 minutes | 10.1 minutes |
| 99.99% | 4 nines | 52.6 minutes | 4.38 minutes | 1.01 minutes |
| 99.999% | 5 nines | 5.26 minutes | 26.3 seconds | 6.05 seconds |
| 99.9999% | 6 nines | 31.5 seconds | 2.63 seconds | 0.605 seconds |
Interview Signal
When asked about availability targets, always clarify: "Are we measuring availability per component, or end-to-end user-facing availability?" A system with 10 sequential dependencies, each at 99.9%, has overall availability of 0.999^10 = 99.0%, not 99.9%.
SLA vs SLO vs SLI
graph TD
SLI[SLI: Service Level Indicator
Actual measurement
e.g., Request success rate = 99.95%]
SLO[SLO: Service Level Objective
Internal target
e.g., Success rate >= 99.9%]
SLA[SLA: Service Level Agreement
Contractual commitment
e.g., Success rate >= 99.5% or refund]
SLI -->|Measured against| SLO
SLO -->|Buffers| SLA
style SLI fill:#1a5490
style SLO fill:#4a7ba7
style SLA fill:#2d5016
Definitions
- SLI (Service Level Indicator): A quantitative measure of service level. Examples: request latency (p99 < 200ms), error rate (< 0.1%), throughput (>= 1000 QPS).
- SLO (Service Level Objective): Internal target for an SLI. Sets the bar for reliability. If you miss SLOs, you slow down feature work to improve reliability.
- SLA (Service Level Agreement): Contractual promise to customers, with consequences (refunds, credits) if violated. Always set SLAs more lenient than SLOs to give yourself error budget.
Real-World Example: AWS EC2 SLA
SLA: AWS commits to 99.99% monthly uptime for EC2 within a single region. If they miss it, you get service credits:
- 99.99% - 99.0%: 10% credit
- < 99.0%: 30% credit
SLO (internal): AWS likely targets 99.995% or higher internally to ensure they don't hit SLA violations.
Error Budgets
The Core Idea
If your SLO is 99.9% availability, you have a 0.1% error budget per month (43.8 minutes of downtime). This budget can be spent on:
- Planned maintenance
- Risky deployments
- Infrastructure changes
- Experiments
Policy: If you exhaust your error budget, you freeze feature releases and focus 100% on reliability improvements until the budget replenishes next month.
graph LR
A[Error Budget Healthy
99.95% actual vs 99.9% SLO]
B[Error Budget Exhausted
99.88% actual vs 99.9% SLO]
A -->|Ship features
Take risks| A
A -->|Incidents| B
B -->|Freeze releases
Focus on reliability| C[Budget Replenishes]
C --> A
style A fill:#2d5016
style B fill:#8b0000
style C fill:#4a7ba7
Interview Signal
Strong candidates frame reliability as a tradeoff, not an absolute. "We target 99.9% because achieving 99.99% would require 10x the operational cost and slower feature velocity. The error budget balances innovation and stability."
7.2 Failure Taxonomy
Not all failures are created equal. Understanding failure modes is critical to designing appropriate mitigations.
graph TD
F[Failure Types]
F --> C[Crash Failures
Process dies]
F --> O[Omission Failures
Message lost]
F --> T[Timing Failures
Too slow]
F --> B[Byzantine Failures
Arbitrary behavior]
F --> G[Gray Failures
Partial degradation]
F --> CA[Cascading Failures
One triggers many]
style C fill:#8b0000
style O fill:#8b4500
style T fill:#b8860b
style B fill:#4b0082
style G fill:#2f4f4f
style CA fill:#800020
1. Crash Failures
What It Is
Process dies suddenly (OOM kill, segfault, unhandled exception, power loss). Clean failure: the process stops responding entirely.
Detection
- Health check timeout
- TCP connection refused
- Process monitoring (systemd, Kubernetes liveness probe)
Mitigation
- Automatic restarts (systemd, Kubernetes ReplicaSet)
- Load balancer removes crashed instance from rotation
- Write-ahead logging (WAL) for state recovery
Real Incident: GitHub Database Failover (2018)
A database replica crashed during a routine maintenance window. The orchestrator detected the crash within 40 seconds and promoted a standby. However, a split-second network partition meant two replicas briefly believed they were primary, causing a brief period of inconsistent writes before automated reconciliation kicked in.
Lesson: Even clean crash failures can have edge cases (split-brain) if failover logic isn't carefully designed.
2. Omission Failures
What It Is
Request or response is lost in transit. Network drops the packet, congestion causes buffer overflow, firewall rule blocks it.
Detection
- Client timeout (no response received)
- TCP retransmission failures (RST packet)
- Application-level ACKs missing
Mitigation
- TCP ensures reliable delivery (automatic retransmission)
- Application-level retries with exponential backoff
- Idempotency to allow safe retries
- Message queues with at-least-once delivery
sequenceDiagram
participant C as Client
participant N as Network
participant S as Server
C->>N: Request
N-xS: Packet dropped!
Note over C: Timeout after 5s
C->>N: Retry request
N->>S: Request delivered
S->>N: Response
N->>C: Response
3. Timing Failures
What It Is
Response arrives, but too late. Service is slow due to overload, GC pause, blocking I/O, or resource contention.
Why It's Dangerous
Client times out and retries, but original request is still processing. Now two requests are in flight, doubling load. This can cascade into retry storms.
Detection
- Latency percentiles (p99, p99.9) exceed thresholds
- Client timeout counters spike
- Slow query logs
Mitigation
- Deadline propagation (pass remaining time budget to downstream services)
- Cancel slow requests (don't waste resources)
- Rate limiting to prevent overload
- Circuit breaker to stop hitting slow dependencies
Real Incident: AWS DynamoDB Overload (2015)
A metadata service became slow under load. Clients timed out after 5 seconds and retried. The retries added more load, making it slower. Within minutes, retry amplification caused a 10x surge in traffic, fully overwhelming the service. It took several hours to recover by aggressively rate-limiting retries.
4. Byzantine Failures
What It Is
Arbitrary or malicious behavior. Process returns wrong results, corrupts data, sends conflicting messages to different peers, or behaves unpredictably.
Causes
- Software bugs (e.g., integer overflow, race condition)
- Bit flips from cosmic rays (rare but real)
- Malicious actors (compromised node)
- Hardware failures (bad RAM, disk corruption)
Detection
- Checksums and cryptographic signatures
- Consensus protocols (Raft, Paxos) detect conflicting votes
- End-to-end validation (don't trust intermediate nodes)
Mitigation
- Byzantine Fault Tolerant (BFT) consensus (requires 3f+1 nodes to tolerate f failures)
- Input validation and output verification
- ECC memory, disk scrubbing
- Immutable logs for forensics
Interview Signal
Most systems don't need BFT consensus (it's expensive). Use it only for adversarial environments (blockchain, multi-tenant infrastructure). For corporate systems, crash-fault-tolerant consensus (Raft, Paxos) is sufficient.
5. Gray Failures
What It Is
The most insidious failure mode. System is partially degraded but not fully failed. Examples:
- 99% of requests succeed, 1% hang for 30 seconds
- Service responds to health checks but returns errors for real traffic
- Disk is slow (high latency) but not dead
- Network drops 5% of packets (enough to slow TCP but not fail)
Why It's Hard
Binary health checks (alive/dead) don't detect it. The system looks healthy to monitoring but users experience failures.
Detection
- Canary requests (send synthetic production-like traffic to test real paths)
- Client-side metrics (measure success rate from caller's perspective)
- Distributed tracing (spot slow spans)
- Outlier detection (one instance slower than peers)
Mitigation
- Adaptive health checks (mark instance unhealthy if error rate or latency exceeds threshold, even if process is alive)
- Circuit breaker (stop sending traffic to degraded instance)
- Graceful restart (kill and restart instances showing symptoms)
graph LR
LB[Load Balancer]
A[Instance A
Healthy
p99=50ms]
B[Instance B
GRAY FAILURE
p99=2000ms]
C[Instance C
Healthy
p99=50ms]
LB --> A
LB -.->|Health check: OK
But 1% of requests hang| B
LB --> C
style A fill:#2d5016
style B fill:#8b4500
style C fill:#2d5016
Real Incident: Google Chubby Gray Failure (2010s)
A Chubby lock service instance passed health checks but had a subtle bug causing 0.01% of lock requests to hang for 30 seconds. This tiny fraction was enough to cause cascading failures in clients (which timed out and retried). The bug was invisible to coarse-grained monitoring. Only fine-grained percentile tracking (p99.9, p99.99) revealed the issue.
6. Cascading Failures
What It Is
One failure triggers others in a domino effect. Classic scenario:
- One instance crashes
- Load balancer shifts traffic to remaining instances
- Increased load causes them to slow down
- Clients timeout and retry, adding more load
- Remaining instances crash from overload
- Entire service collapses
Mitigation
- Capacity planning (N+2 redundancy: system survives losing 2 instances)
- Load shedding (reject new requests if overloaded)
- Circuit breaker (stop sending traffic to failing service)
- Backpressure (slow down upstream instead of crashing)
- Bulkheads (isolate failure domains)
sequenceDiagram
participant C as Client
participant LB as Load Balancer
participant S1 as Server 1
participant S2 as Server 2
participant S3 as Server 3
Note over S1: Crash!
C->>LB: Request
LB->>S2: Route (now 2x load)
C->>LB: Request
LB->>S3: Route (now 2x load)
Note over S2,S3: Overloaded, slow responses
Note over C: Timeout, retry
C->>LB: Retry
C->>LB: Retry
Note over S2: Crash from overload!
Note over S3: Crash from overload!
Note over LB: All instances dead
Real Incident: AWS S3 Outage (2017)
An engineer ran a command to remove a small number of S3 servers. Typo caused the command to remove far more servers than intended. Remaining servers were overloaded. Cascading failures took down large portions of S3 in us-east-1 for 4 hours, impacting thousands of services that depend on S3.
Lesson: Even manual operations can trigger cascades. Always test blast radius of admin commands.
Failure Probability Chains
Serial Dependencies
System availability = product of component availabilities
A_total = A₁ × A₂ × A₃ × ... × Aₙ
Example: 5 services, each 99.9% available → 0.999^5 = 99.5% overall
Parallel Redundancy
If you have N redundant components and only need 1 to work:
A_total = 1 - (1 - A)^N
Example: 3 database replicas, each 99% available → 1 - (1 - 0.99)^3 = 99.9999%
7.3 Redundancy Patterns
Redundancy is the foundation of fault tolerance. Key question: how many copies, and how are they used?
Active-Active
How It Works
All nodes actively serve traffic simultaneously. Load balancer distributes requests across all instances.
Pros
- Maximum resource utilization (no idle standbys)
- Instant failover (just stop routing to failed instance)
- Horizontal scalability (add more instances to handle more load)
Cons
- Requires stateless services or distributed state management
- More complex (need coordination for stateful workloads)
- Cost (running multiple instances at all times)
Use When
- Service is stateless (web servers, API gateways)
- State is externalized (session in Redis, data in DB)
- Need maximum availability and performance
graph TD
LB[Load Balancer]
A1[Instance 1
ACTIVE
Serving 33% traffic]
A2[Instance 2
ACTIVE
Serving 33% traffic]
A3[Instance 3
ACTIVE
Serving 33% traffic]
LB -->|33%| A1
LB -->|33%| A2
LB -->|34%| A3
style A1 fill:#2d5016
style A2 fill:#2d5016
style A3 fill:#2d5016
Real-World: Netflix API Gateway
Netflix's Zuul API gateway runs hundreds of Active-Active instances behind ELBs. Each instance handles requests independently. If an instance crashes, ELB health checks detect it in 30 seconds and stop routing traffic. No user-visible downtime.
Active-Passive
How It Works
One node (primary) serves all traffic. One or more standby nodes are idle, waiting to take over if primary fails.
Pros
- Simpler state management (only one writer at a time)
- Works well for stateful services (databases, message queues)
- No split-brain risk during normal operation
Cons
- Wasted capacity (standbys are idle)
- Failover delay (detection + promotion time)
- Risk of data loss if primary dies before replicating state
Use When
- Service is stateful and hard to distribute (traditional RDBMS)
- Strong consistency required (single writer avoids conflicts)
- Cost-sensitive (fewer active instances)
graph TD
LB[Load Balancer]
P[Primary
ACTIVE
Serving 100% traffic]
S1[Standby 1
PASSIVE
Replicating]
S2[Standby 2
PASSIVE
Replicating]
LB -->|100%| P
P -.->|Replication| S1
P -.->|Replication| S2
style P fill:#2d5016
style S1 fill:#4a7ba7
style S2 fill:#4a7ba7
sequenceDiagram
participant C as Client
participant LB as Load Balancer
participant P as Primary
participant S as Standby
C->>LB: Request
LB->>P: Forward
P->>LB: Response
LB->>C: Response
P->>S: Replicate state
Note over P: Crash!
Note over LB: Detect failure (30s)
Note over LB: Promote standby
Note over S: Now primary
C->>LB: Request
LB->>S: Forward
S->>LB: Response
Hot, Warm, Cold Standby
| Type | State | Failover Time | Cost | Use Case |
|---|---|---|---|---|
| Hot Standby | Fully running, data replicated in real-time, ready to serve immediately | Seconds | High (full infrastructure running) | Mission-critical services (payment processing, stock trading) |
| Warm Standby | Running at reduced capacity, data replicated with slight lag, needs scaling up | Minutes | Medium | Business-critical apps with tolerable brief downtime |
| Cold Standby | Not running, must restore from backup | Hours to days | Low (storage costs only) | Disaster recovery for non-critical systems |
Real-World: AWS RDS Multi-AZ
Hot Standby: RDS maintains a synchronous replica in another Availability Zone. Writes are committed to both primary and standby before acknowledging. If primary fails, RDS automatically promotes standby in 60-120 seconds with zero data loss.
N+1, N+2, 2N Redundancy
N+1 Redundancy
If you need N instances to handle load, run N+1. System survives losing 1 instance.
Example: Need 10 instances for peak load → run 11. If 1 crashes, remaining 10 handle load.
Problem: What if you lose 2? Or if 1 crashes during maintenance on another?
N+2 Redundancy
Run N+2 instances. System survives losing 2 instances simultaneously.
Better for: Production systems where rolling deployments take one instance out of rotation. With N+2, you can do maintenance on 1 while still having N+1 for failures.
2N Redundancy
Run double the required capacity. System survives losing 50% of instances.
Use when: Can't tolerate any degradation even during mass failures (financial systems, medical devices).
Tradeoff: 2x cost.
graph LR
subgraph "N+1: 4 instances for 3x load"
A1[Instance]
A2[Instance]
A3[Instance]
A4[+1 buffer]
end
subgraph "N+2: 5 instances for 3x load"
B1[Instance]
B2[Instance]
B3[Instance]
B4[+1 buffer]
B5[+1 buffer]
end
subgraph "2N: 6 instances for 3x load"
C1[Instance]
C2[Instance]
C3[Instance]
C4[Spare]
C5[Spare]
C6[Spare]
end
Interview Signal
Always mention N+2 for production systems. Explain: "During a rolling deployment, we take 1 instance out. With N+1, we'd have exactly N left, so a single failure during deployment would cause overload. N+2 gives us N+1 even during deployments."
Data Redundancy
Replication
Full Copy Replication: Each replica stores a complete copy of data. Simple but storage-expensive.
- Synchronous replication: Write waits for all replicas to acknowledge. Strong consistency, higher latency.
- Asynchronous replication: Write returns before replicas acknowledge. Low latency, risk of data loss if primary fails before replicating.
- Semi-synchronous: Write waits for at least 1 replica (MySQL semi-sync). Balanced tradeoff.
Erasure Coding
Instead of storing N full copies, split data into M fragments and add K parity fragments. Can reconstruct original data from any M fragments. Survives losing up to K fragments.
Example: (8+4) erasure coding splits data into 8 fragments, adds 4 parity fragments. Total 12 fragments. Can lose any 4 and still reconstruct. Storage overhead: 1.5x (vs 3x for 3-way replication).
Tradeoff: Lower storage cost, but higher CPU cost (encoding/decoding) and slower recovery.
Use when: Cold data (archives, backups) where cost matters more than speed.
Real-World: Facebook f4 Blob Storage
Facebook uses (10+4) Reed-Solomon erasure coding for warm blob storage (photos older than a few months). Reduces storage cost by 2.8x compared to 3-way replication, while still tolerating 4 disk failures.
RAID Levels (Hardware Redundancy)
| RAID Level | Description | Fault Tolerance | Storage Overhead |
|---|---|---|---|
| RAID 0 | Striping (no redundancy) | None (any disk failure loses data) | 0% |
| RAID 1 | Mirroring | Survives N-1 disk failures (out of N disks) | N-1x (e.g., 2x for 2 disks) |
| RAID 5 | Striping + distributed parity | 1 disk failure | 1/N (e.g., 33% for 3 disks) |
| RAID 6 | Striping + double parity | 2 disk failures | 2/N (e.g., 50% for 4 disks) |
| RAID 10 | Mirrored striping | 1 disk per mirror pair | 2x |
7.4 Failover Mechanisms
Redundancy is useless without automatic failover. How do you detect failures and shift traffic?
DNS Failover
How It Works
DNS server returns IP of healthy instance. On failure, DNS updates record to point to standby. Clients re-resolve DNS and start hitting standby.
Pros
- Simple
- Works across regions/clouds
- No single point of failure (DNS itself is distributed)
Cons
- Slow TTL propagation: Even with low TTL (60s), clients and intermediaries cache DNS. Real failover can take 5-15 minutes.
- No session persistence (clients may land on different instance)
- Requires external health checking (DNS server must know instance is down)
Use When
- Cross-region failover (no shared load balancer)
- Can tolerate minutes of downtime
- Stateless services
sequenceDiagram
participant C as Client
participant DNS as DNS Server
participant P as Primary (us-east-1)
participant S as Standby (us-west-2)
C->>DNS: Resolve api.example.com
DNS->>C: 1.2.3.4 (Primary)
C->>P: Request
P->>C: Response
Note over P: Crash!
Note over DNS: Health check fails
Update record
C->>DNS: Resolve api.example.com (cache expired)
DNS->>C: 5.6.7.8 (Standby)
C->>S: Request
S->>C: Response
Anti-Pattern: DNS Failover for Low Latency Services
Don't use DNS failover for services requiring sub-minute recovery. DNS propagation is unpredictable and slow.
Load Balancer Health Checks
How It Works
Load balancer periodically probes each backend instance (e.g., every 5 seconds). If instance fails N consecutive checks (e.g., 3), mark it unhealthy and stop routing traffic. If it passes M consecutive checks (e.g., 2), mark healthy again.
Health Check Types
- TCP check: Try to open TCP connection. Fast but shallow (doesn't verify app is healthy).
- HTTP check: Send GET request, expect 200 OK. Better (checks app layer).
- Custom check: App returns health status based on internal checks (DB connectivity, disk space, etc.). Most thorough.
Configuration
- Interval: How often to check (e.g., 5s). Lower = faster detection, higher = less overhead.
- Timeout: How long to wait for response (e.g., 3s). Must be < interval.
- Unhealthy threshold: Consecutive failures before marking unhealthy (e.g., 3).
- Healthy threshold: Consecutive successes before marking healthy (e.g., 2).
Failover time: Interval × Unhealthy threshold = 5s × 3 = 15s
stateDiagram-v2
[*] --> Healthy
Healthy --> Unhealthy: 3 consecutive failures
Unhealthy --> Healthy: 2 consecutive successes
Healthy: Instance receives traffic
Unhealthy: Instance removed from rotation
AWS ELB Health Check Config
HealthCheck:
Target: HTTP:8080/health
Interval: 5 seconds
Timeout: 3 seconds
UnhealthyThreshold: 3
HealthyThreshold: 2
# Failover time: 15 seconds (5s × 3)
Interview Signal
Mention the tradeoff between detection speed and flapping. If unhealthy threshold = 1, a single transient failure (e.g., GC pause) causes failover, creating churn. Threshold of 3 gives more confidence it's a real failure.
Virtual IP (VIP) Failover
How It Works
Multiple instances share a Virtual IP address. Only one instance claims the VIP at a time. If primary fails, standby detects it (via heartbeat) and claims the VIP. Clients keep using the same IP, unaware of failover.
Protocols
- VRRP (Virtual Router Redundancy Protocol): Standard protocol for VIP failover. Primary sends heartbeats. If standbys stop receiving heartbeats, one promotes itself.
- Keepalived: Linux implementation of VRRP.
Pros
- Fast failover (1-3 seconds)
- Clients don't need to reconnect (same IP)
- Works at network layer (no app changes)
Cons
- Single VIP can't horizontally scale (only one active instance)
- Split-brain risk (two instances claiming VIP if network partitioned)
- Typically limited to same LAN/subnet
Use When
- Stateful services that can't scale horizontally (legacy apps)
- Need fast failover without DNS propagation delay
- Single-writer databases
graph TD
C[Clients connect to VIP: 10.0.1.100]
P[Primary
Owns VIP
Sends heartbeats]
S[Standby
Monitors heartbeats]
C -.-> |VIP routes to| P
P -.->|Heartbeat every 1s| S
style P fill:#2d5016
style S fill:#4a7ba7
sequenceDiagram
participant C as Client
participant VIP as VIP 10.0.1.100
participant P as Primary
participant S as Standby
C->>VIP: Request
VIP->>P: Routed
P->>VIP: Response
VIP->>C: Response
P->>S: Heartbeat
P->>S: Heartbeat
Note over P: Crash!
Note over S: No heartbeat for 3s
Note over S: Claim VIP
C->>VIP: Request
VIP->>S: Routed
S->>VIP: Response
Database Failover
Automatic Failover
Orchestrator (e.g., MySQL Orchestrator, PostgreSQL Patroni, MongoDB Replica Set) monitors replicas. On primary failure:
- Detect failure (timeout on health check or replication lag)
- Elect new primary (most up-to-date replica)
- Reconfigure replicas to follow new primary
- Update VIP/DNS to point to new primary
Time: 30-120 seconds
Manual Failover
DBA manually promotes standby. Slower (minutes to hours) but safer (human validates state before promoting).
Split-Brain Problem
Network partition causes both primary and standby to believe they are primary. Both accept writes. When partition heals, data conflicts. Solution: Use quorum (require majority of replicas to agree before promoting). With 3 replicas, need 2 votes to become primary.
graph TD
O[Orchestrator
Monitors replicas]
P[Primary
Accepting writes]
R1[Replica 1]
R2[Replica 2]
O -.->|Health check| P
O -.->|Health check| R1
O -.->|Health check| R2
P -->|Replication| R1
P -->|Replication| R2
style P fill:#2d5016
sequenceDiagram
participant O as Orchestrator
participant P as Primary
participant R1 as Replica 1
participant R2 as Replica 2
O->>P: Health check
Note over P: Crash!
O-xP: Health check timeout
Note over O: Detect failure
Start election
O->>R1: Get replication position
O->>R2: Get replication position
R1->>O: Position 1000
R2->>O: Position 1005
Note over O: R2 most up-to-date
Promote R2
O->>R2: Promote to primary
O->>R1: Follow R2
Real Incident: GitHub MySQL Split-Brain (2018)
Network partition between two data centers. Both database replicas promoted themselves to primary, both accepted writes for 43 seconds. When partition healed, conflicting writes had to be manually reconciled. Took 24 hours to fully restore service.
Lesson: Always use quorum-based promotion to prevent split-brain.
Stateless vs Stateful Failover
| Aspect | Stateless | Stateful |
|---|---|---|
| Difficulty | Easy | Hard |
| Failover Time | Seconds (just health check delay) | Minutes (must sync state or replay WAL) |
| Data Loss Risk | None (no local state) | Possible (if async replication) |
| Session Handling | Any instance can handle request | Must route to instance with session, or store session externally |
| Example | REST API, web server | Database, message queue, caching layer |
Best Practice: Externalize State
Turn stateful services into stateless ones by externalizing state:
- Store sessions in Redis/Memcached instead of in-process memory
- Store uploads in S3 instead of local disk
- Use distributed caches (Redis Cluster) instead of single-node caches
Now failover is trivial: just route to any healthy instance.
7.5 Circuit Breaker Pattern
One of the most important patterns for preventing cascading failures. When a dependency fails, stop calling it temporarily to let it recover.
The Problem
Your API depends on a downstream database. Database becomes slow (p99 latency goes from 10ms to 5000ms). Your API keeps calling it:
- Each request takes 5 seconds (timeout)
- Threads/connections are blocked waiting
- Request queue builds up
- Your API runs out of threads → starts rejecting requests
- Meanwhile, database is being hammered, can't recover
Insight: Continuing to call a failing dependency makes things worse for both you and the dependency.
The Solution: Circuit Breaker
Circuit breaker sits between you and dependency. Tracks success/failure rate. If failures exceed threshold, "opens" the circuit: stop calling dependency, fail fast immediately. After timeout, try again ("half-open"). If successful, close circuit and resume normal operation.
State Machine
stateDiagram-v2
[*] --> Closed
Closed --> Open: Failure rate > threshold
Open --> HalfOpen: Timeout expires
HalfOpen --> Closed: Success rate > threshold
HalfOpen --> Open: Failure rate > threshold
Closed: Normal operation
Allow all requests
Track success/failure
Open: Fail fast
Reject all requests immediately
Don't call dependency
HalfOpen: Trial period
Allow limited requests
Test if dependency recovered
State Definitions
Closed (Normal)
- Circuit is functioning normally
- All requests pass through to dependency
- Track metrics: success count, failure count, latency
- Transition to Open: If failure rate exceeds threshold (e.g., > 50% of last 100 requests failed)
Open (Failing Fast)
- Dependency is considered unavailable
- All requests fail immediately without calling dependency
- Return cached data, default value, or error
- Transition to Half-Open: After timeout (e.g., 60 seconds)
Why this helps:
- Protects your service (don't waste threads/connections on failing calls)
- Protects dependency (stop hammering it, give it time to recover)
- User experience (fail in 1ms instead of 5000ms)
Half-Open (Testing)
- Allow a small number of trial requests through (e.g., 10)
- If they succeed, dependency has recovered → Closed
- If they fail, dependency still broken → Open
Configuration Parameters
| Parameter | Description | Example Value |
|---|---|---|
| Failure Threshold | % of requests that must fail to open circuit | 50% |
| Minimum Call Count | Minimum requests before calculating failure rate (avoid opening on 1 failure out of 1 request) | 20 |
| Sliding Window Size | Number of recent requests to consider | 100 |
| Wait Duration (Open) | How long to stay open before trying half-open | 60 seconds |
| Permitted Calls (Half-Open) | Number of trial requests in half-open state | 10 |
| Slow Call Threshold | Latency threshold to consider call "slow" | 5 seconds |
| Slow Call Rate Threshold | % of slow calls to open circuit | 100% (all trial calls slow) |
Example Flow
sequenceDiagram
participant A as API
participant CB as Circuit Breaker
participant DB as Database
Note over CB: State: CLOSED
A->>CB: Request
CB->>DB: Forward
DB->>CB: Response (slow, 5s)
CB->>A: Response
Note over CB: Failure count: 1/100
loop 50 more slow responses
A->>CB: Request
CB->>DB: Forward
DB->>CB: Slow response
CB->>A: Response
end
Note over CB: Failure rate: 51/100 = 51%
Exceeds threshold
State: OPEN
A->>CB: Request
CB->>A: Fail fast (no DB call)
Note over CB: Wait 60 seconds
Note over CB: State: HALF-OPEN
A->>CB: Request (trial)
CB->>DB: Forward
DB->>CB: Success (fast)
CB->>A: Response
Note over CB: 10/10 trial requests succeeded
State: CLOSED
Implementation Examples
Java: Resilience4j
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // Open if 50% fail
.minimumNumberOfCalls(20) // Need 20 calls to calculate rate
.waitDurationInOpenState(Duration.ofSeconds(60))
.permittedNumberOfCallsInHalfOpenState(10)
.slidingWindowSize(100)
.slowCallDurationThreshold(Duration.ofSeconds(5))
.slowCallRateThreshold(100) // Open if all calls slow
.build();
CircuitBreaker circuitBreaker = CircuitBreaker.of("database", config);
Supplier<String> decoratedSupplier = CircuitBreaker
.decorateSupplier(circuitBreaker, () -> database.query());
Try<String> result = Try.ofSupplier(decoratedSupplier);
C#: Polly
var circuitBreaker = Policy
.Handle<Exception>()
.CircuitBreakerAsync(
exceptionsAllowedBeforeBreaking: 5,
durationOfBreak: TimeSpan.FromSeconds(60),
onBreak: (ex, duration) =>
{
Log.Warning("Circuit opened for {duration}", duration);
},
onReset: () => Log.Info("Circuit closed")
);
var result = await circuitBreaker.ExecuteAsync(
() => database.QueryAsync()
);
Go: sony/gobreaker
cb := gobreaker.NewCircuitBreaker(gobreaker.Settings{
Name: "database",
MaxRequests: 10, // Half-open trial requests
Interval: time.Minute, // Window size
Timeout: time.Minute, // Open duration
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 20 && failureRatio >= 0.5
},
})
result, err := cb.Execute(func() (interface{}, error) {
return database.Query()
})
Failure Responses: What to Return?
When circuit is open, you can't call dependency. What do you return to user?
Option 1: Return Cached/Stale Data
If you have cached data, return it (mark as potentially stale). Better than error.
Example: Product catalog service can return yesterday's prices.
Option 2: Return Default/Fallback Value
Return a sensible default that allows operation to continue.
Example: Recommendations service returns "popular items" instead of personalized recs.
Option 3: Degrade Gracefully
Disable non-essential features, keep core functionality working.
Example: E-commerce checkout disables "recommended products" widget but allows purchase.
Option 4: Return Error
If no fallback is possible, return clear error. Better to fail fast (1ms) than slow (5000ms).
Interview Signal
Always mention fallback strategies. "When the circuit opens, we return the last known good value from a local cache. This gives users slightly stale data but maintains availability."
Why Circuit Breaker Helps
| Problem | Without Circuit Breaker | With Circuit Breaker |
|---|---|---|
| Cascading Failures | API keeps calling slow DB, exhausts thread pool, API goes down | API stops calling DB, returns fast, stays up |
| Resource Exhaustion | Threads blocked waiting for slow responses | Threads freed immediately, available for other work |
| Dependency Recovery | DB hammered with requests, can't recover | DB gets breathing room to recover |
| User Experience | 5-second timeouts for every request | Instant failure or fallback response |
Real-World: Netflix Hystrix
Netflix pioneered circuit breakers with Hystrix (now in maintenance mode, succeeded by Resilience4j). Every call to a microservice is wrapped in a circuit breaker. When a service fails, callers stop hitting it, preventing cascading failures across hundreds of services. This pattern is core to Netflix achieving 99.99% availability despite constant partial failures.
Anti-Pattern: Global Circuit Breaker
Don't share one circuit breaker for all dependencies. If you open the circuit to Database A, it shouldn't affect calls to Database B. Use one circuit breaker per dependency.
7.6 Retry Strategies
Network requests fail transiently (packet loss, temporary overload, connection hiccup). Retrying can turn transient failures into successes. But naive retries can make things catastrophically worse.
The Danger of Retries
Retry Amplification
Scenario: Service is at 100% capacity. 1% of requests fail. Clients retry failed requests. Now service gets 101% load. More failures. More retries. System collapses under retry storm.
Example: Service handles 10,000 QPS. 1% fail (100 requests). Clients retry each once. Now 10,100 QPS. Service is overloaded. 5% fail (505 requests). Clients retry. Now 10,505 QPS. Failure rate hits 20%. System death spiral.
graph TD
A[Service at 100% capacity
10,000 QPS]
B[1% fail: 100 requests]
C[Clients retry failed requests
Now 10,100 QPS]
D[Service overloaded
5% fail: 505 requests]
E[Clients retry
Now 10,505 QPS]
F[Service collapses
80% fail]
A --> B
B --> C
C --> D
D --> E
E --> F
style A fill:#2d5016
style D fill:#8b4500
style F fill:#8b0000
1. Simple Retry (Dangerous!)
Implementation
for (int i = 0; i < maxRetries; i++) {
try {
return service.call();
} catch (Exception e) {
if (i == maxRetries - 1) throw e;
}
}
Problems
- No delay: Hammers service immediately, worsens overload
- Not idempotent-safe: If request partially succeeded (e.g., wrote to DB but response lost), retry causes duplicate write
- Thundering herd: All clients retry at same time
When to Use
- Never in production. Use for quick prototypes only.
2. Exponential Backoff
How It Works
Wait before retrying. Double the wait time after each failure.
Example: Retry after 1s, then 2s, then 4s, then 8s, then 16s.
Implementation
int delay = 1000; // 1 second
for (int i = 0; i < maxRetries; i++) {
try {
return service.call();
} catch (Exception e) {
if (i == maxRetries - 1) throw e;
Thread.sleep(delay);
delay *= 2;
}
}
Why It Helps
- Gives service time to recover
- Reduces retry rate as failures persist
- Eventually backs off enough to stop hammering
Problem
Thundering herd: If 1000 clients all fail at same time, they all retry after 1s, then 2s, then 4s. Still synchronized.
3. Exponential Backoff + Jitter (Best Practice)
How It Works
Add randomness to delay. Instead of retrying after exactly 2s, retry after 1-2s (random). This desynchronizes clients.
Implementation
int baseDelay = 1000;
int delay = baseDelay;
Random random = new Random();
for (int i = 0; i < maxRetries; i++) {
try {
return service.call();
} catch (Exception e) {
if (i == maxRetries - 1) throw e;
// Full jitter: random between 0 and delay
int jitteredDelay = random.nextInt(delay);
Thread.sleep(jitteredDelay);
delay = Math.min(delay * 2, maxDelay); // Cap at maxDelay
}
}
Jitter Strategies
- Full jitter:
random(0, delay)— Most aggressive desynchronization - Equal jitter:
delay/2 + random(0, delay/2)— Balances desync and average delay - Decorrelated jitter:
random(baseDelay, delay * 3)— AWS recommendation
graph LR
A[1000 clients fail at t=0]
B[Retry with exponential backoff
All retry at t=1s, t=2s, t=4s
Still synchronized]
C[Retry with backoff + jitter
Retry spread across time
Desynchronized]
A --> B
A --> C
style B fill:#8b4500
style C fill:#2d5016
Real-World: AWS SDK
AWS SDKs use exponential backoff with decorrelated jitter. For DynamoDB throttling errors, SDK retries with delays: random(1s, 3s), random(1s, 6s), random(1s, 12s), up to a max of 20s. This prevents retry storms while ensuring clients eventually succeed.
4. Retry Budgets
The Problem
Even with backoff and jitter, retries can amplify load. If service is truly overloaded, retries make it worse.
Solution: Limit % of Retries
Track: retry_ratio = retries / (requests + retries). If retry_ratio exceeds threshold (e.g., 20%), stop retrying. Only allow new requests.
Example: Service gets 10,000 requests. 1,000 fail. Clients want to retry all 1,000. But 1,000/(10,000+1,000) = 9% retry ratio. Allow it. But if 5,000 fail, 5,000/(10,000+5,000) = 33% retry ratio. Exceeds 20% budget. Only allow 3,000 retries. Drop the rest.
Why It Works
- Prevents retry amplification death spiral
- Maintains throughput during overload (prioritize new requests over retries)
- Automatically adapts to load
5. Retry Only Idempotent Requests
The Problem
If request is not idempotent, retrying can cause duplicates.
Example: POST /transfer?amount=100. Request succeeds (money transferred), but response is lost due to network error. Client retries. Now $200 transferred. User's bank account is wrong.
Solution 1: Idempotency Keys
Client generates unique ID for each request. Send it in header: Idempotency-Key: abc123. Server stores key after processing. If it sees same key again, returns cached response without re-executing.
POST /transfer HTTP/1.1
Idempotency-Key: abc123
{ "amount": 100 }
Server checks:
- Is "abc123" in cache? Yes → return cached response
- No → process request, cache result with key "abc123", return response
Solution 2: Only Retry Safe Methods
HTTP GET, HEAD, OPTIONS are safe (read-only, no side effects). Always safe to retry. POST, PUT, DELETE may have side effects. Only retry if idempotent.
Interview Signal
Strong candidates mention: "For reads (GET), we retry up to 3 times with exponential backoff and jitter. For writes (POST), we only retry if the client provides an idempotency key, or if the error is clearly before processing (e.g., connection refused)."
When to Retry vs When to Fail
| Error Type | Retry? | Reasoning |
|---|---|---|
| 503 Service Unavailable | Yes | Transient overload, likely to recover |
| 504 Gateway Timeout | Yes | Slow response, may succeed on retry |
| Connection Refused | Yes | Service may be restarting |
| Network Timeout | Maybe | Request may have succeeded but response lost. Only retry if idempotent. |
| 400 Bad Request | No | Client error, won't succeed on retry |
| 401 Unauthorized | No | Auth issue, won't fix itself |
| 404 Not Found | No | Resource doesn't exist |
| 429 Too Many Requests | Yes (with backoff) | Rate limit, will reset after waiting |
| 500 Internal Server Error | Maybe | Could be transient (e.g., DB deadlock) or permanent (e.g., code bug). Retry once or twice. |
Summary: Retry Decision Tree
graph TD
A[Request Failed]
B{Error Type}
C{Is Request
Idempotent?}
D[Retry with
Exp Backoff + Jitter]
E[Don't Retry
Return Error]
F{Retry Budget
Available?}
G[Retry]
H[Don't Retry
Fail Fast]
A --> B
B -->|4xx Client Error| E
B -->|5xx Server Error| C
B -->|Network Error| C
C -->|Yes| F
C -->|No| E
F -->|Yes| G
F -->|No| H
G --> D
style D fill:#2d5016
style E fill:#8b0000
style H fill:#8b4500
7.7 Bulkhead Pattern
Named after ship bulkheads: watertight compartments that prevent one leak from sinking the whole ship. In software: isolate failure domains so one failing component doesn't bring down everything.
The Problem
Your API has two endpoints:
GET /critical— core business functionGET /analytics— nice-to-have, slow, calls flaky third-party service
Both share the same thread pool (100 threads). Analytics endpoint gets hammered, becomes slow, ties up all 100 threads. Now critical endpoint can't get threads. Entire API is down because of one bad endpoint.
Solution: Isolate Resources
Give each endpoint its own thread pool:
/critical: 80 threads/analytics: 20 threads
If analytics endpoint gets slow, it can only consume its 20 threads. Critical endpoint still has 80 threads available. Failure is contained.
graph TD
A1["Shared Thread Pool 100 threads"]
A1 --> A2
A1 --> A3
B1["Thread Pool 1 80 threads"]
B2["Thread Pool 2 20 threads"]
B1 --> B3
B2 --> B4
style B1 fill:#2d5016
style B2 fill:#4a7ba7
Bulkhead Strategies
1. Thread Pool Isolation
Assign separate thread pools to different workloads.
Java Example: Dedicated ExecutorService
ExecutorService criticalPool = Executors.newFixedThreadPool(80);
ExecutorService analyticsPool = Executors.newFixedThreadPool(20);
// Critical endpoint
@GetMapping("/critical")
public CompletableFuture<String> critical() {
return CompletableFuture.supplyAsync(() ->
service.critical(),
criticalPool
);
}
// Analytics endpoint
@GetMapping("/analytics")
public CompletableFuture<String> analytics() {
return CompletableFuture.supplyAsync(() ->
service.analytics(),
analyticsPool
);
}
2. Connection Pool Isolation
If you have two databases, give each its own connection pool. If DB1 becomes slow and exhausts its pool, DB2 connections are unaffected.
HikariConfig config1 = new HikariConfig();
config1.setMaximumPoolSize(50);
DataSource db1 = new HikariDataSource(config1);
HikariConfig config2 = new HikariConfig();
config2.setMaximumPoolSize(20);
DataSource db2 = new HikariDataSource(config2);
3. Process Isolation
Run critical and non-critical workloads in separate processes. If analytics process crashes (OOM, segfault), critical process is unaffected.
Example: Microservices architecture. Each service is a separate process. One service crashing doesn't kill others.
4. Semaphore-Based Bulkhead
Instead of separate thread pools, use semaphores to limit concurrent executions.
Semaphore criticalSemaphore = new Semaphore(80);
Semaphore analyticsSemaphore = new Semaphore(20);
@GetMapping("/critical")
public String critical() {
criticalSemaphore.acquire();
try {
return service.critical();
} finally {
criticalSemaphore.release();
}
}
@GetMapping("/analytics")
public String analytics() {
if (!analyticsSemaphore.tryAcquire()) {
throw new ServiceUnavailableException("Analytics overloaded");
}
try {
return service.analytics();
} finally {
analyticsSemaphore.release();
}
}
Advantage: Lighter weight than separate thread pools (no extra threads created).
Disadvantage: If analytics blocks all 20 permits, it blocks 20 threads from the shared pool (not isolated).
Sizing Bulkheads
Considerations
- Criticality: Allocate more resources to critical endpoints
- Expected load: Size based on QPS and latency. If endpoint gets 100 QPS with 50ms latency, need ~5 threads (100 * 0.05 = 5)
- Failure blast radius: If endpoint calls flaky dependency, limit its pool size to prevent cascading failures
- Total capacity: Sum of all pools shouldn't exceed total threads (or you'll oversubscribe)
Example Allocation
- Total threads: 200
- Critical endpoints: 120 (60%)
- Important endpoints: 50 (25%)
- Non-critical endpoints: 20 (10%)
- Reserved for overhead: 10 (5%)
Interview Signal
Mention tradeoffs: "Bulkheads add operational complexity (more pools to tune) but dramatically improve isolation. Without bulkheads, one misbehaving endpoint can take down the entire service. We prefer slight over-provisioning to ensure critical paths always have resources."
Real-World: Netflix Hystrix
Hystrix wraps every dependency call in a separate thread pool. If one dependency (e.g., recommendations service) becomes slow, it only consumes threads from its pool. Other dependencies (e.g., user service, payment service) have their own pools and continue working. This pattern is how Netflix survives having 100+ microservice dependencies.
Anti-Pattern: Too Many Bulkheads
Don't create a separate thread pool for every single API endpoint. That creates complexity and wastes resources. Group related endpoints (e.g., all read endpoints, all write endpoints) into shared pools. Only isolate endpoints with very different risk profiles (critical vs nice-to-have, fast vs slow, reliable vs flaky).
7.8 Timeout Strategies
Timeouts are the most underrated reliability mechanism. Without timeouts, your system waits forever for responses that will never come, tying up resources and cascading failures.
Types of Timeouts
1. Connection Timeout
How long to wait when establishing TCP connection. If server doesn't accept connection within this time, fail.
Typical value: 2-5 seconds
Why shorter: Connection either succeeds quickly or won't succeed (server down, network unreachable). No point waiting long.
2. Read Timeout (Socket Timeout)
How long to wait for response after connection is established. If server doesn't send data within this time, fail.
Typical value: 10-30 seconds
Depends on: Expected latency of operation. Fast queries: 1s. Slow reports: 60s.
3. Write Timeout
How long to wait when sending request data to server. If can't send within this time, fail.
Typical value: 10 seconds
Rare to hit: Usually only if network is severely congested or server backpressure.
4. Request Timeout (End-to-End)
Total time from sending request to receiving response, including all retries. This is what user experiences.
Example: User clicks button. UI shows loading spinner. If no response after 30s, show error.
sequenceDiagram
participant C as Client
participant S as Server
Note over C: Connection Timeout: 5s
C->>S: SYN (start TCP handshake)
S->>C: SYN-ACK
C->>S: ACK
Note over C: Connected in 0.1s
Note over C: Write Timeout: 10s
C->>S: Send HTTP request
Note over C: Sent in 0.01s
Note over C: Read Timeout: 30s
Note over S: Processing... (20s)
S->>C: Send HTTP response
Note over C: Received in 20s
Note over C: Total request time: 20.11s
Timeout Configuration Example
Java (Apache HttpClient)
RequestConfig config = RequestConfig.custom()
.setConnectTimeout(5000) // 5 seconds to establish connection
.setSocketTimeout(30000) // 30 seconds to read response
.setConnectionRequestTimeout(2000) // 2 seconds to get connection from pool
.build();
CloseableHttpClient client = HttpClients.custom()
.setDefaultRequestConfig(config)
.build();
Python (requests)
import requests
response = requests.get(
'https://api.example.com/data',
timeout=(5, 30) # (connect timeout, read timeout)
)
Go
client := &http.Client{
Timeout: 30 * time.Second, // Total request timeout
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second, // Connection timeout
}).DialContext,
ResponseHeaderTimeout: 10 * time.Second, // Time to read headers
},
}
Adaptive Timeouts
The Problem with Fixed Timeouts
If you set timeout too low, you get false failures (requests that would've succeeded given more time). If too high, slow requests tie up resources for too long.
Solution: Adaptive Timeouts Based on Percentiles
Measure actual latency distribution. Set timeout at p99.9 (99.9th percentile). This allows 99.9% of requests to succeed while cutting off the slowest 0.1%.
Example: If p99.9 latency is 500ms, set timeout to 1000ms (2x p99.9 for safety margin). As latency changes over time, adjust timeout automatically.
Implementation
// Track latency in sliding window
LatencyTracker tracker = new LatencyTracker(windowSize = 10000);
// Update timeout periodically
scheduler.scheduleEvery(1.minute, () -> {
double p999 = tracker.getPercentile(99.9);
int newTimeout = (int)(p999 * 2); // 2x p99.9
httpClient.setTimeout(newTimeout);
logger.info("Adjusted timeout to {}ms based on p99.9={}", newTimeout, p999);
});
Deadline Propagation
The Problem
User makes request with 30s timeout. Your API calls Service A (20s), which calls Service B (15s), which calls Service C (10s). Total: 45s. Exceeds user's 30s deadline. Request fails, but all three services wasted resources processing it.
Solution: Propagate Deadline
Pass remaining time budget to downstream services. Each service checks: "Do I have enough time to fulfill this?" If not, reject immediately.
sequenceDiagram
participant U as User
participant A as API
participant B as Service A
participant C as Service B
participant D as Service C
U->>A: Request (deadline: 30s)
Note over A: 2s elapsed
Remaining: 28s
A->>B: Request (deadline: 28s)
Note over B: 5s elapsed
Remaining: 23s
B->>C: Request (deadline: 23s)
Note over C: 8s elapsed
Remaining: 15s
C->>D: Request (deadline: 15s)
Note over D: Expected: 10s
Remaining: 15s
OK to proceed
D->>C: Response (10s)
C->>B: Response
B->>A: Response
A->>U: Response (total: 25s)
gRPC Deadlines
// Client sets deadline
ManagedChannel channel = ManagedChannelBuilder.forAddress("localhost", 50051).build();
GreeterGrpc.GreeterBlockingStub stub = GreeterGrpc.newBlockingStub(channel);
HelloReply response = stub
.withDeadlineAfter(30, TimeUnit.SECONDS)
.sayHello(request);
// Server checks remaining time
if (Context.current().getDeadline().timeRemaining(TimeUnit.SECONDS) < 10) {
throw new StatusException(Status.DEADLINE_EXCEEDED.withDescription("Not enough time"));
}
Interview Signal
Strong candidates mention deadline propagation: "We pass a context with deadline through all service calls. Each service checks if it has enough time to process the request. This prevents wasting resources on requests that will timeout anyway."
Timeout Anti-Patterns
1. No Timeout
Worst possible configuration. Threads wait forever. System grinds to a halt.
// DON'T
Socket socket = new Socket("example.com", 80);
socket.getInputStream().read(); // Blocks forever if no data
2. Timeout Longer Than User Will Wait
Setting server timeout to 60s when user gives up after 10s. Server wastes 50s processing abandoned request.
Fix: Server timeout should be ≤ client timeout. Ideally propagate deadline from client.
3. Retrying Without Adjusting Timeout
Initial request has 30s timeout. It times out. Retry also has 30s timeout. But user only waited 30s total, they're already gone. You just wasted another 30s.
Fix: Subtract elapsed time from retry timeout. Or use single overall deadline.
4. Silent Timeout
Request times out, but no logging or metrics. You don't know your system is failing.
Fix: Always emit metrics (timeout_count, timeout_rate) and log timeout events.
7.9 Graceful Degradation
When things go wrong, don't crash entirely. Shed non-essential features and keep core functionality working.
The Philosophy
Perfect is the enemy of good: Users prefer a partially working system over a completely broken one. If your recommendation engine fails, don't fail checkout. If analytics database is down, don't block user signups.
Core Principle
Identify what's essential vs nice-to-have. When failures occur, drop nice-to-have features to keep essential ones running.
Degradation Strategies
1. Feature Flags
Use feature flags to selectively disable features under load or failure.
if (featureFlags.isEnabled("recommendations") && recommendationService.isHealthy()) {
try {
recommendations = recommendationService.get(userId);
} catch (Exception e) {
// Failed, but don't crash. Continue without recommendations.
log.warn("Recommendations unavailable", e);
metrics.increment("recommendations.fallback");
}
}
// Render page with or without recommendations
Automatic Degradation
// Monitor error rate
if (errorRate > 10%) {
featureFlags.disable("recommendations");
log.alert("Auto-disabled recommendations due to high error rate");
}
// Re-enable when recovered
if (errorRate < 1% && timeSinceDisabled > 5.minutes) {
featureFlags.enable("recommendations");
}
2. Fallback Responses
When primary data source fails, return fallback data.
| Feature | Primary Source | Fallback |
|---|---|---|
| Product Recommendations | Personalized ML model | Most popular products |
| User Profile | User database | Anonymous user template |
| Pricing | Real-time pricing service | Cached prices (mark as stale) |
| Search | Elasticsearch cluster | Simple DB LIKE query (slow but works) |
| Shipping Estimate | Carrier API | Static estimate (3-5 days) |
graph TD
A[Request]
B{Primary
Available?}
C[Use Primary]
D{Fallback
Available?}
E[Use Fallback]
F[Return Error]
A --> B
B -->|Yes| C
B -->|No| D
D -->|Yes| E
D -->|No| F
style C fill:#2d5016
style E fill:#b8860b
style F fill:#8b0000
3. Stale Data
Serve cached/stale data when fresh data unavailable. Inform user data may be outdated.
// Try primary database
try {
data = database.query(key);
cache.set(key, data, ttl=5.minutes);
return data;
} catch (DatabaseException e) {
// Database down, serve stale cache
data = cache.get(key);
if (data != null) {
log.warn("Serving stale data for key={}", key);
data.setStale(true); // Mark as potentially outdated
return data;
}
throw new ServiceUnavailableException("Data unavailable");
}
UI Indication
Show banner: "We're experiencing technical difficulties. Data shown may be up to 10 minutes old."
4. Read-Only Mode
When writes are failing but reads work, enter read-only mode. Users can view data but not modify it.
Example: GitHub read-only mode during database incidents. Users can browse code, issues, PRs, but can't push commits or create issues.
if (database.isWriteUnhealthy()) {
systemMode = READ_ONLY;
}
if (systemMode == READ_ONLY && request.method == POST|PUT|DELETE) {
return 503 Service Unavailable {
"error": "System is in read-only mode due to technical issues",
"retry_after": 600
}
}
5. Quality Reduction
Reduce quality to maintain throughput.
| Service | Normal Quality | Degraded Quality |
|---|---|---|
| Video Streaming | 1080p | 720p or 480p |
| Image Processing | Full resolution | Thumbnails only |
| Search Results | 100 results | 10 results |
| ML Inference | Large model (slow, accurate) | Small model (fast, less accurate) |
Decision Framework: What to Degrade?
graph TD
A[Classify Features]
B[Critical
Checkout, Login, Payment]
C[Important
Search, Product Pages]
D[Nice-to-Have
Recommendations, Reviews]
E[Non-Essential
Analytics, Ads, Trackers]
A --> B
A --> C
A --> D
A --> E
B -.-> F[Never degrade
Always keep running]
C -.-> G[Degrade last
Use fallbacks]
D -.-> H[Degrade early
Drop if needed]
E -.-> I[Degrade first
Drop immediately]
style B fill:#8b0000
style C fill:#b8860b
style D fill:#4a7ba7
style E fill:#2f4f4f
Interview Signal
When asked about reliability, mention graceful degradation: "We classify features into tiers. Tier 1 (checkout, payment) never degrades. Tier 2 (search) uses fallbacks. Tier 3 (recommendations) can be disabled. Under load, we shed Tier 3 first, then Tier 2 fallbacks, to protect Tier 1."
Real-World: Amazon Prime Day
During Prime Day 2019, traffic spiked unexpectedly. Amazon automatically degraded:
- Disabled personalized recommendations (fell back to "popular items")
- Simplified search (fewer results, simpler ranking)
- Disabled non-essential images (product images stayed, marketing banners dropped)
- Reduced analytics tracking
Core functionality (browse, add to cart, checkout) remained fast. Most users didn't notice degradation. Revenue was protected.
Anti-Pattern: All-or-Nothing Failure
If recommendations service fails, don't fail the entire page. Render page without recommendations. User can still browse and buy. Throwing 500 Internal Server Error because one widget failed is unacceptable.
7.10 Load Shedding
When overwhelmed by requests, don't try to serve them all and fail. Reject some requests to keep serving others successfully.
The Problem: Overload Collapse
Server can handle 10,000 QPS with 50ms latency. At 12,000 QPS, it slows down to 500ms latency. At 15,000 QPS, it slows to 5000ms latency. All requests timeout. Effective throughput drops to 0.
Better approach: At 10,000 QPS, reject excess requests (return 503). Successfully serve 10,000 QPS. Better to serve 10k users well than fail 15k users completely.
graph LR
subgraph "Without Load Shedding"
A[15,000 QPS]
B[All requests slow
5000ms latency]
C[All timeout
0 successful]
A --> B --> C
end
subgraph "With Load Shedding"
D[15,000 QPS]
E[Reject 5,000
Serve 10,000]
F[10,000 successful
50ms latency]
D --> E --> F
end
style C fill:#8b0000
style F fill:#2d5016
Load Shedding Strategies
1. Rate Limiting (Fixed Window)
Allow N requests per time window. Reject excess.
RateLimiter limiter = RateLimiter.create(10000); // 10k QPS
if (limiter.tryAcquire()) {
return service.handle(request);
} else {
return 503 Service Unavailable {
"error": "Rate limit exceeded",
"retry_after": 1
};
}
Problem: Bursty traffic can exceed limit within window. If limit is 10k/sec and 10k requests arrive in first 100ms, remaining 900ms has 0 capacity.
2. Token Bucket
Bucket refills at fixed rate. Each request consumes token. If bucket empty, reject.
Advantage: Allows bursts (up to bucket capacity) while enforcing average rate.
Example: Bucket capacity 1000 tokens, refill rate 10,000/sec. Can handle bursts of 1000 requests, then throttles to 10k/sec.
// Guava RateLimiter uses token bucket
RateLimiter limiter = RateLimiter.create(10000.0); // 10k tokens/sec
limiter.tryAcquire(); // Consumes 1 token
3. Priority Queues
Not all requests are equal. Prioritize important requests, shed low-priority ones first.
| Priority | Examples | Queue |
|---|---|---|
| Critical | Payment processing, checkout | Always served |
| High | Authenticated user traffic | Served if capacity available |
| Normal | Anonymous browse traffic | Shed if load high |
| Low | Bots, analytics, prefetch | Shed first |
int priority = determinePriority(request);
if (priority == CRITICAL) {
return service.handle(request); // Always process
}
if (systemLoad > 90% && priority == LOW) {
return 503; // Shed low priority
}
if (systemLoad > 95% && priority == NORMAL) {
return 503; // Shed normal priority
}
return service.handle(request);
4. Admission Control
Measure actual system load (CPU, memory, queue depth). Reject requests when overloaded.
if (cpuUsage > 90% || queueDepth > 1000 || latencyP99 > 5000) {
if (random() < 0.5) { // Shed 50% of requests
return 503 Service Unavailable;
}
}
return service.handle(request);
Adaptive: Automatically adjusts rejection rate based on current load. Unlike fixed rate limits, responds to actual system health.
5. CoDel (Controlled Delay)
The Problem with Queue Depth
Traditional approach: reject when queue depth exceeds threshold. But what if queue is deep because you're processing quickly? You'd unnecessarily reject requests.
CoDel Solution
Track how long requests sit in queue (sojourn time). If queue latency exceeds threshold (e.g., 100ms) for a sustained period (e.g., 5s), start dropping requests.
Why better: Distinguishes between "long queue, but draining fast" (OK) vs "long queue, sitting forever" (overload).
// Simplified CoDel
long queueStartTime = now();
long queueEndTime = now();
long sojournTime = queueEndTime - queueStartTime;
if (sojournTime > 100ms) {
overloadCounter++;
if (overloadCounter > 5) { // Sustained for 5 checks
return 503; // Start dropping
}
} else {
overloadCounter = 0; // Reset counter
}
Where to Shed Load
graph TD
A[User]
B[CDN/Edge]
C[Load Balancer]
D[API Gateway]
E[Service]
A --> B
B --> C
C --> D
D --> E
B -.-> F1[Shed: DDoS, bot traffic]
C -.-> F2[Shed: Connection-level rate limiting]
D -.-> F3[Shed: Request-level rate limiting,
priority queues]
E -.-> F4[Shed: Admission control based on
actual system load]
Best Practice: Layer Defense
- Edge/CDN: Block obvious bad traffic (DDoS, known bots, geo-blocking)
- Load Balancer: Connection-level rate limits (max connections per IP)
- API Gateway: Request-level rate limits per user/API key
- Service: Admission control based on internal metrics (CPU, queue depth)
Each layer sheds what it can, protecting downstream layers.
Retry-After Header
When rejecting requests, tell client when to retry:
HTTP/1.1 503 Service Unavailable
Retry-After: 60
{
"error": "Service overloaded",
"message": "Please retry after 60 seconds"
}
Well-behaved clients will honor this and not hammer you with retries.
Real-World: GitHub Load Shedding
During load spikes, GitHub sheds load in layers:
- Git clone/push traffic (high bandwidth) gets lower priority than web traffic
- Anonymous users get lower priority than authenticated users
- Non-critical API endpoints (stars, follows) are disabled
- Search disabled (expensive, non-essential)
- Only core functionality (browse code, view issues) stays up
This keeps the site usable for core workflows even under extreme load.
Interview Signal
"We implement load shedding at multiple layers. At the edge, we use Cloudflare rate limiting to block obvious attacks. At the API gateway, we enforce per-user rate limits. At the service level, we monitor CPU and queue depth. If CPU exceeds 90%, we start shedding low-priority requests. This multi-layer defense ensures we stay up even under 10x load."
7.11 Chaos Engineering
Deliberately break things in production to verify your system can survive failures. Build confidence through controlled experiments.
The Philosophy
"It's better to break things yourself than have them break during a real incident."
Traditional testing: Test in staging, hope it works in prod. Problem: Staging never matches prod (different scale, different dependencies, different failure modes).
Chaos Engineering: Test in prod by injecting failures. Learn what actually breaks. Fix it before it causes real outages.
Principles of Chaos Engineering
1. Define Steady State
Measure normal behavior. Example: p99 latency < 200ms, error rate < 0.1%, throughput >= 10k QPS.
2. Hypothesize Steady State Continues
"We believe that if we kill one instance, the system will continue serving requests with no user-visible impact."
3. Introduce Real-World Chaos
Inject failures that can happen in production: instance crashes, network partitions, disk full, high CPU, slow dependencies.
4. Run Experiments in Production
Staging can't replicate prod scale/complexity. Run chaos in prod (but minimize blast radius).
5. Automate Experiments
Run chaos continuously. Don't just test once and forget. Systems change; chaos must keep pace.
Chaos Experiments
1. Instance Failures
Kill random instances to verify redundancy and failover.
Netflix Chaos Monkey: Randomly kills EC2 instances during business hours. Forces teams to build resilient services that survive instance failures.
# Simulate instance crash
kubectl delete pod <random-pod>
# Verify: Does traffic automatically shift to healthy instances?
# Verify: Does load balancer detect failure and stop routing?
# Verify: Is a new instance automatically started?
2. Network Failures
Inject packet loss, latency, network partitions.
# Add 500ms latency to 10% of packets
tc qdisc add dev eth0 root netem delay 500ms 100ms 10%
# Drop 5% of packets
tc qdisc add dev eth0 root netem loss 5%
# Partition: block traffic between two services
iptables -A INPUT -s 10.0.1.0/24 -j DROP
Verify: Does system detect network issues? Do retries work? Does circuit breaker open?
3. Dependency Failures
Make dependencies slow or unavailable.
# Return errors from dependency
curl -X POST fault-injection-service/inject \
-d '{
"service": "database",
"failure_rate": 0.5,
"latency_ms": 5000
}'
Verify: Does circuit breaker open? Does system use fallbacks? Is core functionality still working?
4. Resource Exhaustion
Consume CPU, memory, disk to simulate resource starvation.
# Stress CPU
stress-ng --cpu 4 --timeout 60s
# Fill disk
dd if=/dev/zero of=/tmp/bigfile bs=1M count=10000
# Memory leak simulation
while true; do malloc 100MB; sleep 1; done
Verify: Does autoscaling kick in? Do instances crash? Do they recover?
5. Time-Based Failures
Clock skew, leap seconds, daylight saving time bugs.
# Set clock forward 1 hour
date -s "$(date -d '+1 hour')"
# Verify: Do certificates still validate? Do distributed locks break?
Netflix Simian Army
Netflix's suite of chaos tools:
- Chaos Monkey: Kills instances
- Chaos Kong: Kills entire AWS region
- Latency Monkey: Injects network latency
- Chaos Gorilla: Kills entire availability zone
- Doctor Monkey: Finds unhealthy instances
- Janitor Monkey: Cleans up unused resources
- Conformity Monkey: Finds instances that don't follow best practices
graph TD
A[Chaos Engineering Tools]
B[Chaos Monkey
Kill instances]
C[Chaos Kong
Kill region]
D[Latency Monkey
Slow network]
E[Litmus
Kubernetes chaos]
A --> B
A --> C
A --> D
A --> E
B -.-> F[Verify: Failover works]
C -.-> G[Verify: Multi-region failover]
D -.-> H[Verify: Timeouts, retries]
E -.-> I[Verify: Pod recovery, health checks]
Litmus: Kubernetes Chaos
CNCF project for chaos engineering on Kubernetes.
apiVersion: litmuschaos.io/v1alpha1
kind: ChaosEngine
metadata:
name: nginx-chaos
spec:
appinfo:
appns: default
applabel: app=nginx
chaosServiceAccount: litmus-admin
experiments:
- name: pod-delete
spec:
components:
env:
- name: TOTAL_CHAOS_DURATION
value: '60'
- name: CHAOS_INTERVAL
value: '10'
- name: FORCE
value: 'false'
Continuously deletes pods every 10s for 60s. Verifies deployments, health checks, and autoscaling work.
Chaos Experiment Template
1. Define Hypothesis
"If we kill 2 out of 10 API instances, remaining 8 will handle load with p99 latency < 300ms."
2. Define Blast Radius
Limit experiment scope to minimize risk. Example: Run on 10% of prod traffic. Have kill switch to abort.
3. Define Steady-State Metrics
- Error rate
- Latency (p50, p99)
- Throughput
- User-facing metric (successful checkouts, API calls)
4. Run Experiment
Inject failure. Monitor metrics in real-time. If metrics degrade beyond acceptable threshold, abort.
5. Analyze Results
- Did hypothesis hold? Why or why not?
- What broke? What worked?
- What improvements are needed?
6. Automate
Turn successful experiment into continuous test. Run weekly or daily.
Game Days
Scheduled chaos engineering events where team simulates major outages.
Example Scenario
- 9:00 AM: Game day starts. Team on call.
- 9:15 AM: Facilitator kills primary database
- Team must: detect failure, promote replica, restore service
- Measure: Time to detect, time to recover, data loss
- 10:00 AM: Debrief. What went well? What failed? What to improve?
Why Valuable
- Tests runbooks and procedures
- Trains team for real incidents
- Finds gaps in monitoring and alerting
- Builds confidence
Real-World: Amazon Prime Video Chaos
Amazon regularly runs chaos on Prime Video:
- Kill video encoding instances (verify new jobs picked up by other instances)
- Inject 10-second delays in CDN (verify player buffers correctly)
- Corrupt video chunks (verify error handling and retry logic)
- Overwhelm API with 10x traffic (verify rate limiting and autoscaling)
This continuous chaos testing ensures Prime Video stays up during peak events like Thursday Night Football.
Interview Signal
"We practice chaos engineering to build resilience. Every week, Chaos Monkey randomly kills instances in production. This forces us to design services that tolerate failures. We also run quarterly game days where we simulate major outages like losing an entire region. This keeps our runbooks current and trains the team for real incidents."
Anti-Pattern: Chaos Only in Staging
Staging is not production. It has different scale, traffic patterns, and failure modes. Chaos in staging won't uncover real production issues. Be brave: run chaos in prod with small blast radius and abort mechanisms.
7.12 Disaster Recovery
Plan for the worst: datacenter fire, regional outage, ransomware, accidental data deletion. Can you recover?
Key Metrics
RPO (Recovery Point Objective)
How much data can you afford to lose?
- RPO = 0: Zero data loss (synchronous replication)
- RPO = 5 minutes: Lose up to 5 minutes of data (async replication with 5-min lag)
- RPO = 24 hours: Lose up to 1 day of data (daily backups)
RTO (Recovery Time Objective)
How quickly must you recover?
- RTO = 0: Instant failover (active-active)
- RTO = 5 minutes: Automatic failover to standby
- RTO = 4 hours: Manual failover, restore from backup
- RTO = 24 hours: Rebuild from scratch
graph LR
A[Disaster Strikes]
B[RPO: Last good backup]
C[RTO: Time to restore]
D[Service Restored]
A --> B
B --> C
C --> D
B -.-> E[Data loss window]
C -.-> F[Downtime window]
style E fill:#8b0000
style F fill:#b8860b
The Tradeoff
Lower RPO/RTO = higher cost and complexity.
| Target | Cost | Complexity |
|---|---|---|
| RPO=0, RTO=0 (active-active multi-region) | Very High (2x+ infrastructure) | Very High (data consistency, conflict resolution) |
| RPO=1min, RTO=5min (hot standby) | High | Medium |
| RPO=1hr, RTO=1hr (warm standby) | Medium | Medium |
| RPO=24hr, RTO=8hr (cold backup) | Low | Low |
Choose based on business impact: How much revenue do you lose per hour of downtime? How critical is the data?
DR Strategies
1. Backup and Restore (Lowest Cost, Slowest Recovery)
Periodically backup data to cold storage (S3 Glacier, tape). On disaster, restore from backup.
- RPO: Backup frequency (e.g., 24 hours for daily backups)
- RTO: Hours to days (depends on data size and restore speed)
- Cost: Very low (storage only)
Best For
Non-critical data, compliance/audit requirements, long-term archival.
Process
- Provision new infrastructure (manual or IaC)
- Install software
- Restore data from backup
- Verify integrity
- Point DNS to new infrastructure
2. Pilot Light (Low Cost, Medium Recovery)
Minimal infrastructure always running in DR region (just database). On disaster, scale up remaining components.
- RPO: Minutes (database continuously replicated)
- RTO: 30 minutes to 1 hour (time to spin up app servers, scale database)
- Cost: Low (just database replicas running)
Best For
Business-critical apps with moderate downtime tolerance.
graph TD
subgraph "Primary Region (us-east-1)"
A1[App Servers
Running]
A2[Database
Running]
end
subgraph "DR Region (us-west-2)"
B1[App Servers
OFF]
B2[Database Replica
Running]
end
A2 -.->|Continuous replication| B2
C[Disaster!]
C --> D[Scale up app servers
Promote DB replica
Update DNS]
style A1 fill:#2d5016
style A2 fill:#2d5016
style B1 fill:#4a4a4a
style B2 fill:#b8860b
3. Warm Standby (Medium Cost, Fast Recovery)
Full infrastructure running in DR region, but at reduced capacity (e.g., 50% of production). On disaster, scale to full capacity.
- RPO: Minutes (continuous replication)
- RTO: 5-15 minutes (time to scale and switch DNS)
- Cost: Medium (50% infrastructure running)
Best For
Critical apps requiring fast recovery.
4. Multi-Site Active-Active (Highest Cost, Instant Recovery)
Full infrastructure running in multiple regions, all serving traffic simultaneously. No failover needed; remaining regions absorb load.
- RPO: 0 (synchronous replication or conflict-free data model)
- RTO: 0 (no failover, automatic load redistribution)
- Cost: Very high (2x+ infrastructure)
- Complexity: Very high (data consistency, conflict resolution, latency)
Best For
Mission-critical systems that can't tolerate any downtime (financial trading, healthcare, emergency services).
graph TD
U[Users]
LB[Global Load Balancer]
subgraph "us-east-1"
A1[App Servers]
A2[Database]
end
subgraph "us-west-2"
B1[App Servers]
B2[Database]
end
subgraph "eu-west-1"
C1[App Servers]
C2[Database]
end
U --> LB
LB -->|33%| A1
LB -->|33%| B1
LB -->|34%| C1
A2 -.->|Bi-directional replication| B2
B2 -.->|Bi-directional replication| C2
C2 -.->|Bi-directional replication| A2
style A1 fill:#2d5016
style B1 fill:#2d5016
style C1 fill:#2d5016
DR Strategy Comparison
| Strategy | RPO | RTO | Cost | Complexity | Use Case |
|---|---|---|---|---|---|
| Backup/Restore | Hours-Days | Hours-Days | 💲 | Low | Non-critical, archival |
| Pilot Light | Minutes | 30-60 min | 💲💲 | Medium | Business-critical |
| Warm Standby | Minutes | 5-15 min | 💲💲💲 | Medium | Critical systems |
| Active-Active | 0 | 0 | 💲💲💲💲 | Very High | Mission-critical |
Testing DR
The Problem
Most organizations have DR plans that look great on paper but have never been tested. When real disaster strikes, they find:
- Backups are corrupted
- Runbooks are outdated
- Required credentials are missing
- DR infrastructure can't handle production load
- Critical dependencies are missing
Solution: Regular DR Tests
- Tabletop exercises: Walk through DR plan on paper. Find gaps in documentation.
- Failover tests: Actually failover to DR region. Verify all systems work.
- Full DR simulation: Simulate complete loss of primary region. Measure RTO/RPO.
Frequency: At minimum, test DR annually. Better: quarterly. Best: continuous (automated DR tests).
Real Incident: AWS S3 Outage (2017)
Engineer ran command to take a few S3 servers offline for maintenance. Typo caused the command to take down far more servers than intended. S3 in us-east-1 was down for 4 hours.
Impact: Thousands of services that depended on S3 went down. Many had "DR plans" that stored backups... in S3. They couldn't recover.
Lesson: DR must be independent of primary infrastructure. If your primary is in AWS, store DR backups in Azure/GCP or on-prem.
Interview Signal
"Our DR strategy is warm standby in another region. We maintain 50% capacity there, with continuous database replication. RTO is 10 minutes, RPO is 1 minute. We test failover quarterly: one Saturday morning, we deliberately fail over to DR, run for 4 hours, then fail back. This keeps our runbooks current and validates our infrastructure can handle production load."
Backup Best Practices
3-2-1 Rule
- 3 copies: Original + 2 backups
- 2 different media: Disk + tape, or cloud + on-prem
- 1 offsite: Protects against site-wide disasters
Immutable Backups
Protect against ransomware. Once written, backup cannot be modified or deleted for retention period (e.g., 30 days). Even if attacker gains admin access, they can't destroy backups.
Test Restores
Regularly restore from backup to verify integrity. Untested backups are useless. Automate restore tests.
Encrypt Backups
Protect sensitive data. Encrypt at rest and in transit.
7.13 Idempotency
The secret to making distributed systems reliable: design operations so that applying them multiple times has the same effect as applying them once.
Why Idempotency Matters
In distributed systems, messages can be:
- Duplicated: Retry sends request twice
- Reordered: Network delivers request 2 before request 1
- Lost and retried: Request succeeded but response lost, so client retries
Without idempotency: Retrying can cause duplicates, incorrect state, financial losses.
With idempotency: Safe to retry. System stays consistent.
Idempotent vs Non-Idempotent Operations
| Operation | Idempotent? | Why |
|---|---|---|
GET /user/123 |
✅ Yes | Read-only, no side effects |
PUT /user/123 {name: "Alice"} |
✅ Yes | Sets name to Alice. Doing it twice = same result |
DELETE /user/123 |
✅ Yes | Deletes user. Deleting twice = still deleted |
POST /user {name: "Bob"} |
❌ No | Creates new user. Doing it twice = 2 users |
POST /account/debit {amount: 100} |
❌ No | Deducts $100. Doing it twice = -$200 |
PATCH /counter/increment |
❌ No | Increments counter. Doing it twice = +2 |
Making Non-Idempotent Operations Idempotent
1. Idempotency Keys
Client generates unique key for each request. Server stores key after processing. If same key arrives again, return cached response without re-executing.
POST /transfer HTTP/1.1
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{
"from": "alice",
"to": "bob",
"amount": 100
}
# Server logic
idempotencyKey = request.headers["Idempotency-Key"]
cachedResult = cache.get(idempotencyKey)
if (cachedResult != null) {
return cachedResult # Already processed
}
# Process transfer
result = transferMoney(from, to, amount)
# Cache result for 24 hours
cache.set(idempotencyKey, result, ttl=24h)
return result
Key Properties
- Client-generated (UUID, hash of request data)
- Unique per request
- Stable (same key for retry of same request)
- Server stores key + response for TTL (24-48 hours typical)
Real-World: Stripe Idempotency Keys
Stripe API requires idempotency keys for all POST requests:
curl https://api.stripe.com/v1/charges \
-u sk_test_xxx: \
-H "Idempotency-Key: 12345" \
-d amount=2000 \
-d currency=usd \
-d source=tok_visa
If network fails and you retry with same key, Stripe returns the original charge. You don't accidentally double-charge the customer.
2. Natural Idempotency (PUT Semantics)
Design operations to be naturally idempotent by using "set to value" instead of "modify by delta".
| Non-Idempotent | Idempotent Alternative |
|---|---|
POST /counter/increment |
PUT /counter {value: 42} |
POST /account/debit {amount: 100} |
PUT /transaction/123 {amount: 100} (transaction ID is dedup key) |
POST /cart/add {item: "apple"} |
PUT /cart {items: ["apple", "banana"]} |
3. Versioning / Compare-and-Swap
Include version number in request. Only apply if current version matches. Prevents applying update twice.
GET /account/123
{
"balance": 1000,
"version": 5
}
# Client modifies balance
PUT /account/123
{
"balance": 900,
"version": 5 // Expected current version
}
# Server checks
if (account.version == request.version) {
account.balance = 900
account.version = 6
return 200 OK
} else {
return 409 Conflict {
"error": "Version mismatch. Account was modified by another request."
}
}
If retry arrives with version 5, but account is now version 6, server rejects it (already applied).
4. Database Constraints
Use unique constraints to prevent duplicates at database level.
CREATE TABLE transactions (
id VARCHAR PRIMARY KEY,
from_account VARCHAR,
to_account VARCHAR,
amount DECIMAL,
created_at TIMESTAMP,
UNIQUE(from_account, to_account, amount, created_at)
);
# Attempt to insert duplicate transaction
INSERT INTO transactions VALUES ('tx123', 'alice', 'bob', 100, '2024-01-15');
INSERT INTO transactions VALUES ('tx123', 'alice', 'bob', 100, '2024-01-15');
# Second insert fails: duplicate key violation
# Application catches error, knows request already processed
At-Least-Once + Idempotency = Exactly-Once
Distributed systems typically provide at-least-once delivery: message may be delivered multiple times, but never lost.
To achieve exactly-once semantics (process message exactly once even if delivered multiple times): make processing idempotent.
# Message queue with at-least-once delivery
while (message = queue.receive()) {
# Idempotent processing using message ID as dedup key
if (!processedMessages.contains(message.id)) {
processMessage(message.data)
processedMessages.add(message.id)
}
queue.ack(message)
}
Even if message delivered twice, it's only processed once.
graph TD
A[Message Sent]
B[Message Delivered]
C{Already
Processed?}
D[Process Message]
E[Skip, Already Done]
F[Ack Message]
A --> B
B --> C
C -->|No| D
C -->|Yes| E
D --> F
E --> F
style D fill:#2d5016
style E fill:#4a7ba7
Interview Signal
"We make all write operations idempotent using client-generated idempotency keys. Clients send a UUID with each POST request. If the server has already processed that UUID, it returns the cached response instead of re-executing. This makes retries safe and gives us exactly-once semantics despite at-least-once delivery from our message queue."
Anti-Pattern: Timestamp as Idempotency Key
Don't use timestamp as idempotency key. Clock skew can cause same timestamp for different requests. Use UUIDs or hash of request content.
7.14 Health Checking Patterns
How do you know if a service is healthy? More nuanced than "is process alive?"
Health Check Types
1. Liveness Probe
Question: Is the process alive?
What it checks:
- Process is running
- Application can respond to HTTP request
What happens if fails: Restart the process.
Example:
GET /health/liveness
200 OK { "status": "alive" }
Kubernetes Liveness Probe
livenessProbe:
httpGet:
path: /health/liveness
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3 # Restart after 3 failures
2. Readiness Probe
Question: Is the service ready to accept traffic?
What it checks:
- Process is running (liveness)
- Dependencies are reachable (database, cache)
- Initialization complete (data loaded, connections established)
- Not overloaded (queue depth, CPU usage below threshold)
What happens if fails: Remove from load balancer rotation (stop sending traffic). Don't restart.
Example:
GET /health/readiness
{
"status": "ready",
"database": "connected",
"cache": "connected",
"queueDepth": 10
}
# If overloaded
{
"status": "not ready",
"database": "connected",
"cache": "connected",
"queueDepth": 5000 # Too high
}
Kubernetes Readiness Probe
readinessProbe:
httpGet:
path: /health/readiness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
failureThreshold: 2 # Remove from service after 2 failures
3. Startup Probe (Kubernetes)
Question: Has the service finished starting up?
Use case: Slow-starting applications (e.g., JVM with large heap, ML models loading).
Why separate from liveness: During startup, app may be slow to respond. Liveness probe would kill it. Startup probe gives more time.
startupProbe:
httpGet:
path: /health/startup
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 30 # Allow 300 seconds (5 min) to start
Once startup probe succeeds, liveness and readiness probes take over.
Liveness vs Readiness: When to Use Which
| Scenario | Liveness | Readiness |
|---|---|---|
| Process deadlocked (infinite loop, stuck thread) | ✅ Fail (restart) | ✅ Fail (stop traffic) |
| Database temporarily unavailable | ✅ Pass (process is alive) | ❌ Fail (not ready to serve traffic) |
| Service starting up, not yet ready | ✅ Pass (process is alive) | ❌ Fail (not ready yet) |
| Service overloaded (queue full) | ✅ Pass (process is alive) | ❌ Fail (can't handle more traffic) |
| Memory leak causing OOM | ❌ Fail (restart to reclaim memory) | ❌ Fail (stop traffic) |
stateDiagram-v2
[*] --> Starting
Starting --> Ready: Startup probe passes
Ready --> NotReady: Readiness probe fails
NotReady --> Ready: Readiness probe passes
Ready --> Dead: Liveness probe fails
NotReady --> Dead: Liveness probe fails
Dead --> [*]: Restart pod
Starting: Startup Probe
Checking if initialized
Ready: Ready Probe
Receiving traffic
NotReady: Not Ready
No traffic, but alive
Dead: Liveness Failed
Restart required
Deep Health Checks
Shallow Health Check
Check if process is alive. Fast but not thorough.
GET /health
200 OK { "status": "ok" }
Deep Health Check
Check dependencies, disk space, memory, critical paths.
GET /health/deep
{
"status": "healthy",
"checks": {
"database": {
"status": "healthy",
"latency_ms": 5,
"connections": { "active": 10, "max": 100 }
},
"cache": {
"status": "healthy",
"hit_rate": 0.95
},
"disk": {
"status": "healthy",
"used_percent": 60
},
"memory": {
"status": "degraded",
"used_percent": 95
}
}
}
When to Use
- Shallow: Liveness probe (called frequently, must be fast)
- Deep: Monitoring dashboards, manual troubleshooting (called infrequently, can be slow)
Anti-Pattern: Health Check Cascading
Service A health check calls Service B health check, which calls Service C health check. If C is down, A and B report unhealthy even though they're fine.
Result: Health checks take down entire system during partial outages.
Fix: Health checks should only check local health. Don't call downstream services in health checks. Monitor dependencies separately.
Anti-Pattern: Expensive Health Checks
Health check that runs complex queries, heavy computations, or waits for locks. Load balancer calls it every 5 seconds. Health checks consume significant resources.
Fix: Health checks must be lightweight. Ping database with simple query (SELECT 1), don't run analytics.
Health Check for Gradual Rollouts
Use readiness probes to control traffic during deployments.
Canary Deployment
- Deploy new version (v2) with readiness probe failing
- No traffic goes to v2 (probe failing)
- Manually test v2
- Mark v2 readiness probe as passing
- Load balancer starts sending small % of traffic to v2
- Monitor metrics. If good, gradually increase v2 traffic
- If bad, mark v2 readiness probe as failing. Traffic stops immediately.
Real-World: AWS ELB Health Checks
ELB pings each instance every 5-30 seconds (configurable). If instance fails 3 consecutive checks, marked unhealthy and removed from rotation. If it passes 2 consecutive checks after being unhealthy, added back.
This automatically handles instance failures, deployments, and temporary overload without manual intervention.
Interview Signal
"We use separate liveness and readiness probes. Liveness checks if the process is alive (returns 200). Readiness checks dependencies: can we reach the database? Is queue depth below threshold? If readiness fails, we stop sending traffic but don't restart. This prevents cascading failures when a dependency is temporarily unavailable."
Summary
Reliability and fault tolerance are about embracing failure. Systems fail constantly. The question is: can your system survive gracefully?
Key Takeaways
1. Availability Math
- 99.9% = 8.76 hours downtime/year
- 99.99% = 52 minutes downtime/year
- Serial dependencies multiply failure rates: (0.999)^10 = 99.0%
- Use error budgets to balance innovation and stability
2. Failure Types
- Crash failures: easiest to detect and handle
- Omission failures: retries + idempotency
- Timing failures: timeouts + circuit breakers
- Byzantine failures: rare, expensive to handle
- Gray failures: hardest to detect (use percentiles, outlier detection)
- Cascading failures: use bulkheads, load shedding, circuit breakers
3. Redundancy Patterns
- Active-Active: max utilization, instant failover, harder to implement
- Active-Passive: simpler, some wasted capacity, failover delay
- N+2 for production (survives 1 failure during maintenance)
- Hot standby (seconds), warm standby (minutes), cold standby (hours)
4. Core Patterns
- Circuit Breaker: Stop calling failing dependencies, fail fast
- Retry with Backoff + Jitter: Safely retry transient failures
- Bulkhead: Isolate failure domains with separate resource pools
- Timeout: Never wait forever, propagate deadlines
5. Graceful Degradation
- Classify features by criticality (never degrade core, shed nice-to-have)
- Use fallbacks: cached data, default values, reduced quality
- Load shedding: reject some requests to successfully serve others
6. Chaos Engineering
- Test in production (staging != prod)
- Netflix Chaos Monkey: randomly kill instances
- Game days: simulate major outages, test runbooks
- Automate chaos experiments for continuous validation
7. Disaster Recovery
- RPO: how much data loss can you tolerate?
- RTO: how fast must you recover?
- Strategies: backup/restore (cheap, slow) → active-active (expensive, instant)
- Test DR regularly (quarterly minimum)
8. Idempotency
- Make operations safe to retry
- Idempotency keys for non-idempotent operations
- At-least-once delivery + idempotency = exactly-once semantics
9. Health Checking
- Liveness: is process alive? (fail → restart)
- Readiness: can it serve traffic? (fail → stop routing)
- Keep health checks lightweight
- Don't cascade health checks (check local health only)
Interview Checklist
When asked about reliability in a system design interview, cover:
- Redundancy: How many instances? Active-active or active-passive? N+2?
- Failover: How do you detect failures? How do you route traffic away?
- Circuit Breaker: How do you handle slow/failing dependencies?
- Retries: Exponential backoff + jitter + idempotency keys
- Timeouts: Connection, read, write, end-to-end timeouts
- Graceful Degradation: What features can you drop under load?
- Health Checks: Liveness vs readiness, what do they check?
- Disaster Recovery: What's your RPO/RTO? How do you test it?
Key phrase: "Everything fails. We design for resilience by..."
The Hierarchy of Reliability
graph TD
A[Level 5: Chaos Engineering
Proactively test failures]
B[Level 4: Disaster Recovery
Survive regional outages]
C[Level 3: Graceful Degradation
Shed load, disable features]
D[Level 2: Automatic Failover
Circuit breakers, health checks]
E[Level 1: Redundancy
Multiple instances, replicas]
F[Level 0: Single Point of Failure
One server, one DB]
F --> E
E --> D
D --> C
C --> B
B --> A
style F fill:#8b0000
style E fill:#b8860b
style D fill:#4a7ba7
style C fill:#2d5016
style B fill:#2d5016
style A fill:#2d5016
Start at Level 1: You must have redundancy. No single points of failure.
Mature to Level 2: Automate failover. Don't rely on humans to detect and fix failures.
Advanced to Level 3: Gracefully degrade instead of failing completely.
Production-grade Level 4: Have DR plan and test it.
Elite Level 5: Practice chaos engineering. Break things before they break you.
Recommended Reading
- Site Reliability Engineering (Google) — SLOs, error budgets, incident management
- Release It! (Michael Nygard) — Stability patterns, anti-patterns, real-world incidents
- Designing Data-Intensive Applications (Martin Kleppmann) — Reliability, consistency, fault tolerance
- Chaos Engineering (Netflix) — Principles and practices
- AWS Well-Architected Framework: Reliability Pillar — Practical AWS-specific guidance
Next Chapter Preview
In Chapter 8: Observability & Monitoring, we'll cover how to see inside your black-box distributed system:
- Metrics, Logs, Traces (the three pillars)
- What to measure (USE, RED, Four Golden Signals)
- Alerting strategies (symptom-based, not cause-based)
- On-call best practices
- Distributed tracing for debugging latency
You can't fix what you can't see. Observability turns invisible distributed systems into transparent, debuggable ones.
Metastable Failures
Definition: A metastable failure occurs when a system enters a self-sustaining failure state that persists AFTER the initial trigger is removed. Unlike cascading failures (which are progressive), metastable failures are stable in the failed state.
This is the failure mode that separates production-experienced engineers from those who haven't seen true scale. Metastable failures require manual intervention because the system cannot recover on its own.
Key Characteristics
- Self-sustaining: The failure generates more load that perpetuates the failure
- Persistent after trigger removal: Fixing the root cause doesn't restore the system
- Positive feedback loop: Degradation creates conditions that worsen degradation
- Requires manual intervention: System cannot auto-recover
Example 1: Cache Stampede → Database Overload
- t=0: Cache goes down (initial trigger)
- t=1: All requests hit database directly → DB overloaded → requests timeout
- t=2: Clients retry failed requests → retry queue grows
- t=5: Cache restored (trigger removed)
- t=6: Database STILL overloaded because retry queue now contains 5 minutes of accumulated requests
- t=10: System stays in failed state indefinitely because retries keep DB overloaded
Key insight: Removing the trigger (cache outage) did not restore the system. The retry queue created a new, self-sustaining overload.
Example 2: Leader Election Thrashing
- Leader elected → immediately receives full traffic load
- Leader overloaded → crashes within seconds
- New leader elected → same traffic surge → crashes
- Repeat forever (system oscillates, never stabilizes)
Root cause: No traffic ramping. New leader gets 100% traffic instantly, cannot handle it.
Example 3: Circuit Breaker Cascade
- Primary backend overloaded → circuit breaker opens
- All traffic redirects to backup backend
- Backup backend overloaded → its circuit breaker opens
- No backend available → all requests fail
- Primary backend recovers, but nobody sends traffic (circuit still open)
Key insight: System is "stuck" with all circuit breakers open even though backends are healthy.
Root Cause: Positive Feedback Loops
Normal system: Negative feedback loops stabilize the system (increased load → shed load → reduced load)
Metastable failure: Positive feedback loops amplify the failure (increased load → more retries → even more load)
The failure mode GENERATES the conditions that sustain the failure.
Detection
- Before trigger: System metrics normal
- During trigger: System metrics degrade (expected)
- After trigger removed: System metrics STILL degraded (unexpected)
- Characteristic sign: Manual intervention required to recover
Monitoring: Graph CPU, latency, error rate, queue depth. If trigger (e.g., cache outage) ends at t=5 but metrics don't improve, suspect metastable failure.
Solutions
| Technique | Purpose | Implementation |
|---|---|---|
| Retry Budgets | Cap total retries to prevent unbounded retry accumulation | Limit retries to 10% of normal traffic. Example: if normal load = 10K req/s, max retries = 1K req/s. Drop excess retries. |
| Adaptive Load Shedding | Shed load proportional to latency increase | If p99 latency > 2x normal, shed 50% of traffic. If p99 > 5x, shed 90%. Prevents total collapse. |
| Dampened Recovery | Slowly ramp up traffic after recovery, don't instantly restore full load | After failure, ramp from 10% → 25% → 50% → 100% over 10 minutes. Prevents immediate re-overload. |
| Kill Switches | Manually shed non-critical traffic during metastable state | Feature flags to disable analytics, recommendations, low-priority features. Keep only critical path. |
| Queue Depth Limits | Bound queues to prevent unbounded retry accumulation | Set max queue size = 5 minutes of normal traffic. Drop new items if queue full. Prevents hour-long backlogs. |
Real-World Incidents
Facebook BGP Outage (2021):
- BGP misconfiguration disconnected Facebook data centers from internet
- DNS authoritative servers became unreachable → DNS cache entries expired globally
- After BGP fixed, thundering herd of DNS queries overloaded DNS servers
- System stayed down for 6+ hours even after BGP restored because DNS couldn't handle query storm
- Metastable component: DNS query retry storm
AWS DynamoDB Partition Storm (2015):
- Metadata service became slow → clients retried metadata requests
- Retries overloaded metadata service → made it slower → more retries
- Positive feedback loop: slow service → retries → slower service
- Even after fixing metadata service, retry backlog kept it overloaded
- Solution: AWS deployed retry quotas and adaptive throttling
Prevention Strategies
- Capacity headroom: Run services at 50% capacity, not 90%. Leaves room for retry spikes.
- Exponential backoff with jitter: Spread retries over time, prevent synchronized retry storms
- Circuit breakers with half-open state: Gradually test if backend recovered before sending full traffic
- Load shedding before queuing: Drop requests early instead of queuing them indefinitely
- Separate retry queues: Don't mix retries with new requests. Prioritize new requests.
- Global rate limiters: Cap total system QPS, not just per-client QPS
Interview Tip: Metastable Failures vs Cascading Failures
Cascading failure: Service A fails → Service B fails → Service C fails (progressive failure spreading through system)
Metastable failure: System enters stable failed state that persists after trigger removed (self-sustaining failure)
Why this matters: "Metastable failures are what separates Staff engineers from Senior engineers. If you can identify and explain this pattern, you're demonstrating production depth that most candidates lack. This is the failure mode that wakes you up at 3 AM and won't fix itself."
Testing for Metastable Failures
- Chaos experiment: Kill cache for 5 minutes. Restore cache. Observe: does system recover or stay degraded?
- Load test with retries: Run load test at 2x capacity with aggressive retry logic. Observe: does system stay overloaded after load test ends?
- Circuit breaker test: Manually open all circuit breakers. Close them. Observe: does traffic gradually restore or immediately overload backends?
Key Takeaway: Metastable failures are the most insidious failure mode in distributed systems because the system appears "broken" even after you've fixed the root cause. The failure creates its own sustaining load (retry queues, circuit breaker states, thundering herds) that prevents recovery. Always design systems with negative feedback loops (load shedding, rate limiting, dampened recovery) to prevent positive feedback loops that lead to metastable states.