SE

Fault tolerance

Must-know concept1.5 hAdvanced

Circuit breaker, retries, backoff, graceful degradation.

Failures aren't exceptions; they're a property of distributed systems. Build in retries, timeouts, circuit breakers, and graceful degradation — your reliability isn't the weakest dependency, it's how well you handle the weakest dependency failing.

The big idea

Every network call will eventually fail. The interesting question is how the failure cascades. Three tools, in escalating order:

Timeoutdon't wait forever
Retry with backoff + jittera few attempts, not a thundering herd
Circuit breakerstop hammering a sick dependency
Graceful degradationserve something useful with what works

Always set a timeout

The default in many libraries is "wait forever." A user request that hangs for 30s is worse than one that fails in 1s — it ties up threads, connection pool slots, and patience.

const res = await fetch(url, {
  signal: AbortSignal.timeout(2000),     // 2 second deadline
});

The right timeout depends on the work, but a useful rule: the request budget should be fixed. If your API has 1s to respond, all internal calls combined must be under 1s. Don't let a slow dependency steal more than its share.

Retry — carefully

Retries fix transient failures, not bugs. Three rules:

  1. Only retry idempotent operations

    Retrying a POST /charge may charge twice. Use idempotency keys, or only retry GET/PUT/DELETE.

  2. Exponential backoff

    Wait 100ms, then 200ms, then 400ms… don't retry in a tight loop.

  3. Add jitter

    Without jitter, every client of a failed service retries at the same moment and re-overloads it on recovery (the "thundering herd").

async function callWithRetry<T>(
  fn: () => Promise<T>,
  { maxAttempts = 3, baseMs = 100 } = {},
): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try { return await fn(); } catch (err) {
      if (attempt >= maxAttempts || !isRetryable(err)) throw err;
      const backoff = baseMs * 2 ** (attempt - 1);
      const jitter  = Math.random() * baseMs;
      await sleep(backoff + jitter);
    }
  }
}

Circuit breaker

A sick dependency that times out on every request is worse than one that fails fast — each timeout ties up resources. The circuit breaker watches failure rates and trips when they spike, failing every subsequent call instantly for a cooldown period.

  1. t=0Closed

    normal — calls pass through, failures counted

  2. t=1Threshold hit

    50% errors in last 60s; circuit opens

  3. t=1+30sOpen

    all calls fail-fast without touching the dependency

  4. t=1+30s+Half-open

    let one call through; if it succeeds, close again

Use a library (Polly, Resilience4j, opossum); rolling your own is full of edge cases.

Bulkheads — isolate the blast radius

If one dependency goes slow and consumes the whole thread pool, every endpoint becomes slow. Give each dependency its own small pool. When it dies, only its callers are affected; the rest of the app keeps serving.

Graceful degradation

When something inevitable breaks, what does your system do?

Stale-while-revalidate
Serve the last cached value while you retry in the background.
Skip the optional bit
Search index down? Hide the search box. The product page still loads.
Read-only mode
Database primary down? Allow reads from replicas; show "writes are temporarily unavailable."
Static fallback
A pre-rendered error page is infinitely better than a stack trace.

Idempotency keys

For mutations across services, send a client-generated idempotency key. Servers deduplicate by it. Retries become safe.

POST /charges HTTP/1.1
Idempotency-Key: a1b2c3d4
Content-Type: application/json
 
{"amount_cents": 1000, "customer_id": 42}

If the server crashes after charging but before responding, the client retries with the same key — the server recognises it, doesn't charge again, returns the previous response.

In practice

Most outages are not "a server died" but "a downstream got slow, threads piled up, the whole thing fell over." Defending against latency is harder than defending against crashes. Test it: in staging, inject 5-second delays into a dependency and see what breaks.

Key takeaways

  • Set timeouts on every network call; never wait forever.
  • Retry idempotent operations with exponential backoff + jitter; not in a tight loop.
  • Use a circuit breaker so a sick dependency fails fast instead of hanging.
  • Bulkheads isolate failures — separate thread pools / connection pools per dependency.
  • Plan graceful degradation: stale data, partial features, read-only mode beat a blank page.
  • Idempotency keys make retries safe across services.

Checkpoint questions

Use these to test whether the lesson is clear enough to explain without rereading.

  1. 1What timeout budget would you assign before retrying or failing a request?
  2. 2When are retries helpful, and when do they make an outage worse?
  3. 3How does a circuit breaker protect the rest of the system?
  4. 4What feature could degrade gracefully if a dependency is unavailable?

References

External resources for going deeper after the lesson above.