System DesignIntermediate 14 min Lesson 28 of 42

Fault Tolerance

Assume every dependency will fail and design for it: timeouts, bounded retries, circuit breakers, bulkheads and fallbacks.

System Design · Lesson 28 of 42
0/42 done(0%)

What is it? #

Fault tolerance is designing so that failures in one part do not cascade into total failure.

Availability is about redundancy of components. Fault tolerance is about how your code behaves when something it depends on is slow, broken or unreachable.

The dangerous case is not a dependency that fails fast. It is one that hangs. Requests pile up waiting, your workers are exhausted, and a problem in one downstream service takes your whole application down.

Four techniques cover most of it: timeouts, bounded retries with backoff, circuit breakers and fallbacks.

Think of it like this #

A restaurant where the dessert supplier fails to deliver. A fault-tolerant restaurant removes dessert from the menu for the evening and keeps serving.

A fragile one has every waiter standing at the back door waiting for the delivery, so nobody gets their main course either.

Simple example #

Your product page calls a recommendation service. It becomes slow, every request waits 30 seconds, your worker pool fills, and the entire site goes down — because of an optional feature.

Code #

PYTHON
import time
import requests


# 1. Always set a timeout. This is the single most important line.
def fetch_recommendations(user_id: int) -> list:
    response = requests.get(
        f"https://recs.internal/users/{user_id}",
        timeout=(1.0, 2.0),          # 1s to connect, 2s to read
    )
    response.raise_for_status()
    return response.json()


# 2. Circuit breaker: stop calling a service that is clearly broken
class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_seconds=30):
        self.failure_threshold = failure_threshold
        self.recovery_seconds = recovery_seconds
        self.failures = 0
        self.opened_at = None

    def call(self, func, *args, **kwargs):
        if self.opened_at is not None:
            if time.time() - self.opened_at < self.recovery_seconds:
                raise CircuitOpen("circuit is open; not calling")
            self.opened_at = None            # half-open: allow one trial call

        try:
            result = func(*args, **kwargs)
        except Exception:
            self.failures += 1
            if self.failures >= self.failure_threshold:
                self.opened_at = time.time()
            raise
        else:
            self.failures = 0                # success closes the circuit
            return result


class CircuitOpen(Exception):
    pass


# 3. Fallback: degrade instead of failing
breaker = CircuitBreaker()

def product_page(product_id: int, user_id: int) -> dict:
    product = database.fetch_product(product_id)     # essential
    try:
        recommendations = breaker.call(fetch_recommendations, user_id)
    except Exception:
        recommendations = popular_products()          # optional: degrade
    return {"product": product, "recommendations": recommendations}
TEXT
The four techniques

timeout          never wait indefinitely; every network call needs one
retry + backoff  a bounded number of attempts, with increasing delay and jitter
circuit breaker  stop calling a failing service; let it recover
bulkhead         separate resource pools, so one dependency cannot exhaust everything

Plus one decision per dependency: essential, or degrade?

How it works #

The timeout is the foundation. Without it, a hanging dependency holds your worker forever. The connect and read timeouts are separate because they fail for different reasons.

The circuit breaker counts failures. After the threshold, it opens and calls fail immediately without touching the network. That protects two things: your own workers, which stop waiting, and the struggling service, which stops receiving traffic while it recovers.

After the recovery period, the breaker allows one trial call. Success closes it; failure opens it again. That is the half-open state, and it is how the system recovers automatically.

The fallback is where the design decision lives. The product itself is essential — no fallback is possible, so that failure must surface. Recommendations are optional, so a generic list is better than an error page.

Retries deserve care. They must be bounded, spaced with exponential backoff, and jittered so all clients do not retry in unison. Retrying immediately against an overloaded service makes the incident worse, and retrying a non-idempotent operation can duplicate it.

Bulkheads complete the set: separate connection pools or worker groups per dependency, so a slow service can only exhaust its own share.

Real-world use #

Cascading failure is how small incidents become outages. One slow service fills the callers' thread pools, those callers become slow, and their callers fill up in turn.

Service meshes and libraries such as Resilience4j and Polly provide these patterns as configuration. Cloud SDKs usually have built-in retry and timeout settings, which are worth reviewing rather than accepting by default.

Load shedding is the related idea: when overloaded, reject some requests quickly rather than accepting everything and serving nothing. A fast 503 is better than a timeout.

Testing this requires deliberately breaking things — chaos engineering in its practical form. Turning off a dependency in staging reveals whether your fallbacks actually work, which is usually not what people expect.

The most valuable habit is simply asking, for each dependency: what happens if this is slow, and what happens if it is gone?

Common mistakes #

  • No timeout on a network call, which is the root cause of most cascading failures.
  • Unbounded retries, which turn a small outage into a self-inflicted denial of service.
  • Retrying without backoff and jitter, so every client retries in unison.
  • Treating optional dependencies as essential, so any failure breaks the page.
  • Never testing failure paths, so fallbacks are broken when they are finally needed.

Practice #

Take an endpoint that calls an external service. Add an explicit timeout, a circuit breaker with a threshold and recovery period, and a fallback. Then simulate the service being unreachable and confirm the endpoint still responds quickly with degraded content.

Quick quiz

  1. 1. What is the most dangerous kind of dependency failure?

  2. 2. What does a circuit breaker do when open?

  3. 3. Why add jitter to retry delays?

  4. 4. What is a bulkhead in this context?

  5. 5. What decision must be made for each dependency?

Summary

  • Fault tolerance is about surviving failures in things you depend on.
  • Every network call needs an explicit timeout.
  • Retries must be bounded, backed off and jittered.
  • Circuit breakers stop you hammering a broken service and let it recover.
  • Decide per dependency whether to fail or degrade — and test it.