System Design

Fault-Tolerant Systems: Circuit Breakers, Retries, Bulkheads

Master fault-tolerant system design with circuit breakers, retries, and bulkheads. Learn to build resilient microservices that handle failures gracefully.

Khader Vali August 27, 2026 9 min read

Designing Fault-Tolerant Systems: Circuit Breakers, Retries, & Bulkheads

In the vast, interconnected landscape of modern software, where systems are increasingly distributed and composed of numerous independent services, the concept of “failure” is no longer an anomaly but an inevitability. Network glitches, service overloads, database timeouts, and even outright service crashes are just a few of the challenges that can disrupt the smooth operation of an application. As senior engineers, our mission is not to prevent all failures—a Sisyphean task—but to design systems that anticipate, withstand, and gracefully recover from them. This is the essence of fault tolerance.

At Khadervali.com, we believe in empowering developers with the knowledge to build robust and scalable solutions. Today, we’re diving deep into three fundamental patterns that form the bedrock of fault-tolerant system design: Retries, Circuit Breakers, and Bulkheads. These patterns, when skillfully applied, transform fragile systems into resilient ones, capable of navigating the turbulent waters of distributed computing.

The Imperative for Fault Tolerance in Distributed Systems

Once upon a time, applications were often monolithic, running on a single server. Failures were typically catastrophic for the entire application. While simpler to understand, this architecture offered limited scalability and resilience. The shift to microservices and distributed systems, while bringing immense benefits in terms of agility, scalability, and independent deployment, also introduced new layers of complexity and new failure modes.

Consider a typical microservice architecture: a user request might traverse an API Gateway, an authentication service, a user profile service, a product catalog service, a recommendation engine, and finally, a payment processing service. Each of these services might be running on a different machine, potentially in a different data center, communicating over a network that is inherently unreliable. A failure in any one of these services, or even a temporary network hiccup, can quickly propagate and bring down the entire user experience—a phenomenon known as a “cascading failure.”

Fault tolerance is the ability of a system to continue operating, perhaps at a reduced level, in the event of component failure. It differs from mere high availability, which often focuses on minimizing downtime through redundancy. A fault-tolerant system actively handles and isolates failures, preventing them from spreading and ensuring that the system can recover or degrade gracefully. This directly impacts Mean Time To Recovery (MTTR), a critical metric for operational excellence.

The goal is to prevent a single, transient problem from evolving into a widespread outage. Without fault-tolerant mechanisms, a slow database query could exhaust connection pools across multiple services, leading to a complete system freeze. A dependency service experiencing a spike in latency could cause client requests to pile up, consuming all available threads and memory, eventually crashing the entire application.

Fault-Tolerant Systems: Circuit Breakers, Retries, Bulkheads
Generated Image

Retries: The First Line of Defense Against Transient Failures

When interacting with external services or even internal components, it’s common to encounter temporary glitches. These are known as transient failures. They might include momentary network interruptions, a service briefly overloading and returning 503 Service Unavailable, a database locking up for a second, or a timeout due to a garbage collection pause. In such scenarios, the simplest and often most effective strategy is to just try again.

Understanding Transient Failures

The key here is “transient.” Retrying a request makes sense if the underlying issue is expected to resolve itself quickly. It’s crucial to differentiate these from permanent failures, such as a 404 Not Found (resource genuinely missing), a 401 Unauthorized (bad credentials), or a logic error returning a 400 Bad Request. Retrying a permanent failure is futile and can even exacerbate problems by wasting resources and increasing load on the failing system.

Basic Retry Mechanism

The most straightforward retry strategy involves simply retrying a failed operation after a fixed delay, up to a maximum number of attempts. While better than no retry at all, this approach has significant drawbacks.

  • Thundering Herd Problem: If many clients simultaneously retry after the same fixed delay, they can create a “thundering herd” effect, hitting the recovering service all at once and potentially overwhelming it again.
  • Immediate Re-failure: If the service is still struggling, immediate retries might just keep hitting it while it’s down, consuming resources on both sides.

Let’s look at a basic Python implementation:

import time
import random

def call_service_unreliably():
    # Simulate a service call that might fail
    # Fails roughly 1/3 of the time due to current time modulo 3
    if random.random() < 0.3: # Using random for more consistent failure simulation
        raise ConnectionError("Service unavailable temporarily")
    print("Service call succeeded.")
    return "Success!"

def basic_retry(max_attempts=3, delay_seconds=1):
    print(f"Attempting to call service with basic retry (max {max_attempts} attempts, {delay_seconds}s delay)...")
    for attempt in range(max_attempts):
        try:
            result = call_service_unreliably()
            print(f"Attempt {attempt + 1}: {result}")
            return result
        except ConnectionError as e:
            print(f"Attempt {attempt + 1} failed: {e}. Retrying in {delay_seconds}s...")
            time.sleep(delay_seconds) # Fixed delay
    raise Exception(f"Service failed after {max_attempts} basic retries.")

# Example usage:
# print("--- Basic Retry Test ---")
# try:
#     basic_retry()
# except Exception as e:
#     print(f"Application level caught: {e}")

Exponential Backoff

To mitigate the “thundering herd” and give the struggling service more time to recover, we introduce exponential backoff. This strategy increases the delay between retries exponentially. For instance, the first retry might wait 1 second, the second 2 seconds, the third 4 seconds, and so on. This spreads out the retries over a longer period, reducing the chance of overwhelming the service.

import time
import random

# Reusing call_service_unreliably from above for consistency
# def call_service_unreliably():
#     if random.random() < 0.3:
#         raise ConnectionError("Service unavailable temporarily")
#     print("Service call succeeded.")
#     return "Success!"

def exponential_backoff_retry(max_attempts=5, base_delay=1.0):
    print(f"\nAttempting to call service with exponential backoff (max {max_attempts} attempts, base delay {base_delay}s)...")
    for attempt in range(max_attempts):
        try:
            result = call_service_unreliably()
            print(f"Attempt {attempt + 1}: {result}")
            return result
        except ConnectionError as e:
            print(f"Attempt {attempt + 1} failed: {e}.")
            delay = base_delay * (2 ** attempt) # Exponential increase
            print(f"Retrying in {delay:.2f} seconds...")
            time.sleep(delay)
    raise Exception(f"Service failed after {max_attempts} exponential backoff retries.")

# Example usage:
# print("--- Exponential Backoff Retry Test ---")
# try:
#     exponential_backoff_retry()
# except Exception as e:
#     print(f"Application level caught: {e}")

Exponential Backoff with Jitter

Even with exponential backoff, if many clients encounter a failure at roughly the same time, their retry schedules might still synchronize, causing staggered but still coordinated bursts of requests. This is where jitter comes in. Jitter adds a small, random variation to the calculated backoff delay. This randomization further disperses the retry attempts, preventing synchronized “thundering herds” and giving the recovering service a smoother path to full recovery.

A common approach is “full jitter,” where the delay is a random number between 0 and the current exponential backoff value. Another is “decorrelated jitter,” which uses a random value between the previous delay and three times the previous delay, ensuring the delays keep growing while still being randomized.

import time
import random

# Reusing call_service_unreliably from above for consistency
# def call_service_unreliably():
#     if random.random() < 0.3:
#         raise ConnectionError("Service unavailable temporarily")
#     print("Service call succeeded.")
#     return "Success!"

def exponential_backoff_with_jitter_retry(max_attempts=5, base_delay=1.0, max_jitter_factor=0.5):
    print(f"\nAttempting to call service with exponential backoff and jitter (max {max_attempts} attempts, base delay {base_delay}s, jitter factor {max_jitter_factor})...")
    for attempt in range(max_attempts):
        try:
            result = call_service_unreliably()
            print(f"Attempt {attempt + 1}: {result}")
            return result
        except ConnectionError as e:
            print(f"Attempt {attempt + 1} failed: {e}.")
            base_calculated_delay = base_delay * (2 ** attempt)
            # Full jitter: random delay between 0 and base_calculated_delay
            jitter_delay = random.uniform(0, base_calculated_delay)
            actual_delay = jitter_delay # For full jitter, this is the delay

            # Another option: bounded jitter (e.g., random around the base_calculated_delay)
            # jitter_range = base_calculated_delay * max_jitter_factor
            # actual_delay = max(0, base_calculated_delay + random.uniform(-jitter_range, jitter_range))

            print(f"Retrying in {actual_delay:.2f} seconds (base: {base_calculated_delay:.2f}, jitter applied)..")
            time.sleep(actual_delay)
    raise Exception(f"Service failed after {max_attempts} retries with jitter.")

# Example usage:
# print("--- Exponential Backoff with Jitter Retry Test ---")
# try:
#     exponential_backoff_with_jitter_retry()
# except Exception as e:
#     print(f"Application level caught: {e}")

Idempotency and Retries

A critical consideration when implementing retries is idempotency. An operation is idempotent if executing it multiple times has the same effect as executing it once. This is vital because with retries, a request might be processed multiple times by the target service, even if the client only intended it once (e.g., the service processed the request but the response got lost, leading the client to retry). If the operation is not idempotent, retrying it could lead to undesirable side effects, such as duplicate orders, multiple charges, or incorrect data updates.

  • GET requests are inherently idempotent.
  • PUT requests (for full resource replacement) are generally idempotent.
  • DELETE requests are generally idempotent (deleting an already deleted resource has no further effect).
  • POST requests (for creating new resources or arbitrary actions) are typically NOT idempotent by default.

For non-idempotent operations, you must design them to become idempotent. This can involve:

  • Using unique transaction IDs or correlation IDs that the service checks before processing. If a request with the same ID has already been processed, the service simply returns the original result without re-executing the logic.
  • Conditional updates, e.g., “update only if the current version is X.”
  • Using database constraints to prevent duplicate insertions.

Circuit Breakers: Preventing Cascading Failures

While retries are excellent for transient failures, they can be detrimental when a service is truly down or critically impaired. Continuously retrying a failing service just adds load to it, delaying its recovery, and consumes resources (CPU, memory, network) in the calling service. This is where the Circuit Breaker pattern comes into play.

The Analogy and Core Concept

Think of an electrical circuit breaker in your home. If there’s a short circuit or an overload, the breaker “trips,” cutting off power to that part of the house. This prevents damage to appliances and the electrical system. Similarly, a software circuit breaker wraps calls to a potentially failing service. If the service starts failing consistently, the circuit breaker “trips” or “opens,” preventing further calls from reaching the failing service. Instead, it immediately returns an error to the caller, effectively failing fast. This gives the failing service time to recover without being hammered by more requests, and it protects the calling service from wasting resources waiting for a response that will never come.

The Three States: Closed, Open, and Half-Open

A circuit breaker typically operates in three states:

Written by

Khader Vali

Senior Software Engineer specializing in cloud architecture, real-time systems, and enterprise-scale applications.

Share this article

Related Articles

Designing Event-Driven Microservices: A Practical Guide

Aug 22, 2026 · 16 min read

Building Performant Web Apps with WebAssembly in Rust

Aug 03, 2026 · 16 min read

Build Feature Flag Systems for Continuous Delivery

Jun 22, 2026 · 15 min read