28.5. Fault Tolerance
Table of Contents
Why Fault Tolerance Matters
Fault tolerance is the ability of your backend system to continue working correctly when parts of it fail. In production, something is always failing: a node restarts, a network link drops, a disk becomes slow, a dependency has an outage.
Your goal is not to avoid every failure, which is impossible, but to design the system so that failures are contained, predictable, and recoverable.
Fault tolerance principle:
You will have failures. Design so that failures are expected, isolated, and handled instead of surprising and catastrophic.
In this chapter, the focus is on techniques and patterns that help your backend survive and recover from real-world problems, not on eliminating them.
Types of Failures in Production Systems
Before applying techniques, you need a vocabulary for failures. Different failures require different defenses.
Fail-stop vs Partial failures
- Fail-stop
A component clearly stops working. - Examples:
- A container crashes with an error.
- A process exits with non-zero status.
- A database node is completely unreachable.
- Partial / Degraded
The component is alive but behaves badly. - Examples:
- Responses become very slow.
- Only some requests fail.
- High error rate, but health checks still pass.
Partial failures are often more dangerous because they are harder to detect and can cause cascading problems.
Transient vs Permanent failures
- Transient failures
- Short-lived, often fix themselves.
- Examples:
- DNS lookup fails for a few seconds.
- Network timeout on one request.
- A database connection is dropped, then reconnects.
- Permanent failures
- Will not fix themselves without manual action or replacement.
- Examples:
- Misconfigured environment variable.
- Data corruption.
- Application code bug.
Fault-tolerant systems usually:
- Retry transient failures.
- Fail fast and escalate permanent failures.
Local vs Cascading failures
- Local failure
- Only affects the component where it happens.
- Example: One worker process crashes but others keep running.
- Cascading failure
- The initial failure spreads to other components.
- Example:
- Database becomes slow.
- API servers wait and pile up requests.
- Threads / connections run out.
- Entire system becomes unavailable.
Fault tolerance is largely about preventing local failures from becoming cascading failures.
Design Principles for Fault-Tolerant Systems
Prefer graceful degradation over total failure
A system that degrades is better than one that stops.
Examples of graceful degradation:
- The search feature is temporarily disabled, but core actions like checkout still work.
- A recommendation service is down, so you show a default list of popular items.
- The image thumbnail generator is overloaded, so some images show placeholders.
Try to answer:
- "If this dependency is down, what is the minimum acceptable behavior?"
Avoid single points of failure
A single point of failure (SPOF) is one component whose failure brings down the whole system.
Common SPOFs:
- Single database instance with no replica.
- One application server without a backup.
- One load balancer.
- Single availability zone or region.
Approaches:
- At least 2 instances of critical components, behind a load balancer.
- Database replicas, with a strategy for failover.
- Use managed services where possible, which often provide built-in redundancy.
Design with failure as a normal path
In code, you often treat failures as "rare exceptions". In production, they are normal.
Practical implications:
- Timeouts are explicit and tuned.
- Retries are planned, not added ad-hoc.
- Errors have clear messages and structured metadata.
- Logging is enough to understand what happened, but not noisy.
A helpful mindset: every external call is "best effort", not guaranteed.
Redundancy and Replication
Redundancy is the core building block of fault tolerance: many weak parts can create a strong system.
Redundancy patterns
Horizontal redundancy
Run multiple instances of the same component.
Example:
- 3 API servers behind a load balancer.
- 2 worker processes consuming the same queue.
Benefits:
- If one instance fails, others continue to serve.
- Support for rolling deployments.
Vertical redundancy
Increase the capacity or resources of a single machine:
- More CPU, RAM, disk.
Vertical redundancy alone does not solve fault tolerance, because the machine is still a single point of failure. Use it with horizontal redundancy, not instead of it.
Data replication
For stateful components like databases, simply adding more instances is not enough; their data must be replicated.
Common patterns:
- Primary / replica (master / slave):
- One primary handles writes.
- One or more replicas replicate data and serve reads.
- Multi-primary / multi-leader:
- Several nodes accept writes; conflicts must be resolved.
- Quorum-based / consensus systems:
- Systems like etcd, Consul, or some NoSQL databases use majority votes to ensure consistency.
Trade-offs:
- More replicas increase availability but also complexity.
- Strong consistency vs eventual consistency:
Some systems guarantee that everyone sees the same data at the cost of latency. Others accept temporary inconsistency to stay available.
Timeouts, Retries, and Backoff
External calls can fail or hang. Timeouts and retries are key tools to keep your application responsive and robust.
Timeouts
A timeout is the maximum time your code waits for an operation before giving up.
Why timeouts are critical:
- Without them, stuck calls can exhaust your threads or async tasks.
- They limit the impact of slow dependencies.
- They let you fail fast and trigger fallback logic.
Example in pseudocode:
# Bad: no timeout
response = http_client.get("https://payments.example.com/pay")
# Better: explicit timeout
response = http_client.get("https://payments.example.com/pay", timeout=2.0)Choose timeouts based on:
- Normal response times of the dependency.
- How long the caller can reasonably wait.
- End-to-end latency budget of the whole request.
Rule: Every call to an external system must have a timeout.
No uncontrolled waiting for remote services.
Retries
Retries help with transient failures:
- Short network glitches.
- Temporary overloads.
- DNS hiccups.
Do not blindly retry on every failure. Use selective retries:
- Retry on timeouts and network errors.
- Retry on specific 5xx responses (like 502, 503, 504) from dependencies.
- Do not retry on 4xx errors or clear permanent errors.
Example retry logic (simplified):
max_retries = 3
for attempt in range(max_retries):
try:
response = http_client.get(url, timeout=1.5)
if response.status_code == 200:
return response
elif response.status_code in (502, 503, 504):
# Retryable server errors
continue
else:
break
except NetworkError:
# Retry on network errors
continue
raise ExternalServiceError("Service unavailable after retries")Exponential backoff
If many clients retry at the same time, they can amplify a problem and create a "retry storm".
Exponential backoff slows down retries after each failure, which gives the remote system time to recover.
Example backoff sequence:
- 1st retry: wait 0.5 s
- 2nd retry: wait 1 s
- 3rd retry: wait 2 s
- 4th retry: wait 4 s
Formula for backoff interval:
$$
t_n = t_0 \cdot 2^{(n - 1)}
$$
where:
- $t_0$ is the initial delay,
- $n$ is the retry attempt number.
Often, you also add jitter (randomness) to avoid synchronized retries.
Example:
import random
import time
base_delay = 0.5
max_retries = 5
for attempt in range(1, max_retries + 1):
try:
return call_remote_service()
except TransientError:
delay = base_delay * (2 ** (attempt - 1))
# Add jitter of +/- 50%
jitter = random.uniform(0.5, 1.5)
sleep_time = delay * jitter
time.sleep(sleep_time)
raise ExternalServiceError("Failed after retries with backoff")Circuit Breakers
A circuit breaker protects your system from repeatedly calling a failing dependency and making things worse.
It is inspired by electrical circuit breakers:
- When there is too much current, the breaker opens and stops the flow.
- Later it can be reset.
Circuit breaker states
A typical circuit breaker has three states:
| State | Description | Behavior |
|---|---|---|
| Closed | Everything is normal. | Allow all requests. Count failures. |
| Open | The dependency is considered unhealthy. | Immediately reject calls. No remote attempt. |
| Half-open | Testing if the dependency has recovered. | Allow a limited number of test calls. |
Transition logic (simplified):
- Closed β Open
When failures exceed a threshold (percentage or count) in a time window. - Open β Half-open
After a fixed "cool down" period. - Half-open β Closed
When enough test calls succeed. - Half-open β Open
When test calls fail again.
Why circuit breakers help
Without a circuit breaker:
- Your service keeps sending requests to a failing dependency.
- Each request waits for a timeout.
- Threads or async tasks are busy waiting.
- Latency spikes and other parts of your system suffer.
With a circuit breaker:
- Once the dependency is clearly failing, calls are short-circuited.
- The caller can immediately:
- Return an error quickly.
- Use a fallback or cached response.
- The failing dependency gets less load and can recover faster.
Example behavior:
- Your payment provider is down.
- Circuit breaker opens after 50% of calls fail in a few seconds.
- For the next 30 seconds, your API instantly returns "Payments are temporarily unavailable. Please try again later."
- After 30 seconds, a few real requests are allowed to see if the provider is back.
Bulkheads and Isolation
The bulkhead pattern separates parts of your system so that a failure in one area does not sink the entire "ship".
Resource isolation
Typical shared resources:
- Thread pools.
- Database connections.
- Message queue consumers.
- CPU / memory.
If one feature or dependency uses all of a shared resource, others get starved.
Bulkheads aim to:
- Reserve a portion of resources for critical paths.
- Limit the damage from noisy or failing components.
Examples:
- Separate thread pools:
- One for normal HTTP requests.
- One for slow external calls.
- Separate connection pools:
- One pool for critical database operations.
- One pool for reporting or analytics queries.
This way, if analytics queries go wild, they cannot consume all database connections and block the core business operations.
Example scenario
You have:
/checkoutendpoint (critical)./reports/salesendpoint (non-critical).
Without bulkheads:
- Reports endpoint starts many long queries.
- Database connections are exhausted.
- Checkout endpoint cannot get a connection, so it fails.
With bulkheads:
- Checkout uses a dedicated connection pool of 20 connections.
- Reports use another pool of 10 connections.
- Even if reports consume all 10, checkout still has 20 guaranteed.
Idempotency and Safe Retries
Idempotency is crucial for fault tolerance because retries can safely be applied only when operations are idempotent or at least safe to repeat.
What is idempotency?
An operation is idempotent if performing it multiple times has the same effect as performing it once.
Examples:
- Setting user email to "alice@example.com" is idempotent.
- Charging a credit card 10 USD is not idempotent if it charges each time.
In HTTP:
- GET, PUT, DELETE are defined as idempotent methods (in theory).
- POST is usually non-idempotent.
Idempotency helps with:
- Safe retries of operations when the response is unknown.
- Recovery from partial failures (for example client lost the response).
Idempotency keys
For operations that are not naturally idempotent, you can make them idempotent with an idempotency key.
Typical pattern:
- Client generates a unique idempotency key, for example a UUID.
- Client sends it in a header or in the body, for example
Idempotency-Key: 123e4567-e89b-12d3-a456-426614174000. - Server stores:
- The key.
- The result (success or failure).
- Any side effects (like created order id).
- If the same key is sent again:
- Server does not execute the action again.
- Instead it returns the same result as the first time.
This is very useful for:
- Payment APIs.
- Order creation.
- Any operation where double execution is dangerous.
Idempotency does not eliminate all complexity, but it greatly simplifies safe retries and recovery.
Handling Partial Failures and Fallbacks
Some failures are local and can be worked around with fallback behavior.
Detecting partial failures
You can detect partial failures through:
- Health checks:
- Liveness: is the process alive?
- Readiness: is it ready to serve traffic?
- Metrics:
- Error rate.
- Latency.
- Timeouts.
- Connection or request errors:
- TLS errors.
- Network timeouts.
Fallback strategies
Examples of fallbacks:
- Use cached data if the live service is unavailable.
- Return a default response or simplified behavior:
- Show last known prices.
- Hide advanced features that depend on the failing service.
- Gracefully reject specific operations:
- "Recommendations are currently unavailable, but you can still browse products."
Practical question for each dependency:
- "What should we do if this is slow?"
- "What should we do if this is down?"
Document the answers, and implement them in code.
Graceful Degradation and Feature Flags
Feature flags help you turn off risky parts quickly without redeploying.
Feature flags for fault tolerance
Use feature flags to:
- Disable non-critical features during an incident.
- Roll out new features gradually and turn them off if they cause issues.
- Route some traffic away from problematic dependencies.
Examples:
- Flag
enable_advanced_search: - If search backend is overloaded, turn off advanced filters and use a simpler query.
- Flag
use_new_payments_provider: - If new provider has errors, quickly switch back to old one.
Feature flag systems can be:
- Simple environment variables or configuration values.
- Centralized services with UI and per-user rules.
Graceful degradation with flags
Combine feature flags with graceful degradation:
- If the recommendation service error rate rises:
- Automatically or manually set feature flag to "simple mode".
- Show generic recommendations instead of personalized ones.
- During peak load:
- Turn off expensive background tasks or non-critical scheduled jobs.
Self-Healing and Automated Recovery
Fault-tolerant systems often use self-healing mechanisms to detect and fix failures automatically.
Health checks and restarting
Many orchestrators and platforms, such as Kubernetes or Docker, support:
- Liveness probes:
- Determine if the application is running.
- If failing, restart the container or process.
- Readiness probes:
- Determine if the application is ready to serve requests.
- If failing, stop routing traffic to the instance.
Patterns:
- If the app is stuck or crashed, liveness fails and it is restarted.
- If the app is starting up or cannot reach dependencies, readiness fails and it is removed from traffic.
Auto-scaling and replacement
In cloud environments:
- If a node is unhealthy, it is automatically replaced.
- If load is high, more instances are added.
- If load drops, extra instances are removed.
This does not replace good application behavior, but complements it.
Cleanup and compensation
Self-healing also includes:
- Cleaning up stuck jobs.
- Retrying failed background tasks with safe strategies.
- Running compensation actions for partially completed workflows, for example:
- If payment succeeded but order creation failed, automatically refund or complete the order.
Observability for Fault Tolerance
You cannot build fault-tolerant systems without understanding how they fail. Good observability is essential.
Key elements:
- Logs:
- Include correlation IDs or trace IDs to follow a request across services.
- Log failures with clear context and structured fields.
- Metrics:
- Error rates per endpoint and per dependency.
- Latency distribution (p50, p95, p99).
- Saturation: CPU, memory, queue length.
- Traces:
- Show how long each part of a request took.
- Highlight which dependency caused slowness.
For fault tolerance, you should:
- Alert on symptoms, like increased latency or error rate, not only on individual server health.
- Use dashboards to see if:
- Retries are increasing.
- Circuit breakers are opening.
- Queues are growing.
Trade-offs and Limitations
Every fault-tolerance technique has a cost. Overdoing them can hurt simplicity and performance.
Examples of trade-offs:
- More retries increase load on dependencies.
- Short timeouts reduce waiting but may increase false failures.
- Many replicas increase availability but also operational cost and complexity.
- Strong consistency can reduce availability in distributed systems.
- Complex patterns, like circuit breakers and bulkheads, add code paths that must be tested.
A useful guideline:
- Apply stronger fault-tolerance mechanisms:
- To more critical flows.
- At higher scale.
- In more failure-prone environments.
- Keep non-critical flows simpler.
Summary
Fault tolerance is about expecting failure and planning for it. Core techniques include:
- Redundancy and replication to avoid single points of failure.
- Timeouts, retries, and exponential backoff to handle transient errors.
- Circuit breakers to stop hammering failing dependencies.
- Bulkheads and isolation to protect critical paths from noisy neighbors.
- Idempotency and safe retries to avoid double side effects.
- Graceful degradation and feature flags to keep the system usable during incidents.
- Self-healing and automated recovery to fix some failures without human intervention.
- Observability to see and understand failures when they happen.
In production backend engineering, you do not aim for a system that never fails, but for one that fails predictably, recovers gracefully, and keeps the most important features working.
Views: 17
KAHIBARO