System Design

Fault Tolerant Systems: Circuit Breakers, Retries, Bulkheads

Learn to design robust, fault-tolerant systems using Circuit Breakers, Retries, and Bulkheads. Master these essential patterns to prevent cascading failures and enhance system resilience.

Khader Vali calendar_today May 27, 2026 schedule 16 min read

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

In the world of modern software, especially with the proliferation of microservices and cloud-native architectures, failure is not an anomaly—it’s an inevitability. Network glitches, database timeouts, third-party API rate limits, or even an unexpected spike in traffic can bring down an otherwise perfectly functioning system. The goal isn’t to prevent all failures, but to design systems that can gracefully handle them, contain their impact, and recover autonomously. This is the essence of fault tolerance.

As senior engineers, our responsibility extends beyond just making things work; we must make them work reliably under duress. This article will delve into three foundational patterns for building fault-tolerant systems: the Retry pattern, the Circuit Breaker pattern, and the Bulkhead pattern. We’ll explore their individual mechanics, understand how they complement each other, and see practical implementations with code examples and architectural considerations.

The Unreliable Nature of Distributed Systems

Before we dive into solutions, let’s acknowledge the problem. Why are distributed systems inherently prone to failure?

  • Network Latency and Unreliability: Messages can be lost, delayed, or duplicated. Connections can drop. DNS can fail.
  • Resource Exhaustion: A single misbehaving service can consume all available CPU, memory, or network sockets, starving other critical services.
  • Dependency Failures: Your service relies on dozens of other services, databases, caches, and third-party APIs. If one of them goes down, it can directly impact your service.
  • Transient vs. Permanent Failures: Some failures are temporary (a momentary network blip, a brief database lock), while others are persistent (a service is completely offline, a bug in the code). Distinguishing between them is crucial.
  • Cascading Failures: This is the boogeyman of distributed systems. A small failure in one component can propagate rapidly, bringing down an entire system, much like a domino effect. Imagine a service timing out, causing upstream services to backlog requests, leading to their own timeouts, and so on.

Our objective is to build systems that are resilient to these challenges, capable of absorbing failures without collapsing entirely, and recovering efficiently. This is where our three patterns come into play.

The Retry Pattern: Giving It Another Shot

What is the Retry Pattern?

The Retry pattern is perhaps the simplest and most intuitive fault-tolerance mechanism. When a service call or operation fails, instead of immediately giving up, the client simply tries again. This pattern is particularly effective for handling transient errors—those that are temporary and likely to resolve themselves quickly, such as momentary network outages, database deadlocks, or brief service unavailability due to deployment.

However, retries must be implemented thoughtfully. Naive retries can exacerbate problems, especially during periods of high load or when dealing with persistent failures. Imagine thousands of clients simultaneously retrying against an already struggling service; this “thundering herd” effect can quickly overwhelm it and prevent recovery.

Key Considerations for Retries

  1. Idempotency: The most critical factor. An operation is idempotent if it can be applied multiple times without changing the result beyond the initial application. Read operations (GET) are typically idempotent. Most write operations (POST, PUT, DELETE) need careful design to be idempotent. For example, if you retry a non-idempotent “transfer money” operation, you might inadvertently transfer money multiple times.
  2. Retry Limits: Never retry indefinitely. Set a maximum number of retries to prevent infinite loops and resource exhaustion.
  3. Delay between Retries (Backoff Strategy):
    • Fixed Delay: Waiting a constant amount of time (e.g., 500ms) between retries. Simple, but can lead to “thundering herd” if many clients retry simultaneously.
    • Linear Backoff: Increasing the delay by a fixed amount each time (e.g., 1s, 2s, 3s). Better than fixed, but still predictable.
    • Exponential Backoff: The most recommended strategy. The delay increases exponentially with each retry (e.g., 1s, 2s, 4s, 8s). This spreads out retries over time, reducing contention.
    • Jitter: Add a small, random amount of delay to the exponential backoff. This further helps in preventing synchronized retries and spreading the load more evenly. For example, instead of waiting exactly 2 seconds, wait 2 seconds +/- 200ms.
  4. Error Classification: Only retry for transient errors. Differentiate between errors that are likely to resolve (e.g., HTTP 503 Service Unavailable, network timeout) and those that are permanent (e.g., HTTP 400 Bad Request, HTTP 404 Not Found, authentication errors).
  5. Timeout: Combine retries with an overall timeout for the entire operation, including all retries.

Code Example: Retry with Exponential Backoff and Jitter

Let’s look at a Python example using a custom decorator for implementing retries with exponential backoff and jitter.

import time
import random
import logging
from functools import wraps

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

def retry(max_retries=3, initial_delay=0.5, max_delay=10, backoff_factor=2, jitter=0.1):
    """
    A decorator to retry a function call with exponential backoff and jitter.

    :param max_retries: Maximum number of times to retry the operation.
    :param initial_delay: The initial delay in seconds before the first retry.
    :param max_delay: The maximum delay in seconds between retries.
    :param backoff_factor: Factor by which the delay increases (e.g., 2 for exponential).
    :param jitter: A fractional amount of jitter to add/subtract from the delay.
                   e.g., 0.1 means +/- 10% of the calculated delay.
    """
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            current_delay = initial_delay
            for attempt in range(1, max_retries + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_retries:
                        logging.error(f"Function '{func.__name__}' failed after {max_retries} attempts. Error: {e}")
                        raise

                    logging.warning(f"Attempt {attempt}/{max_retries} for '{func.__name__}' failed: {e}. Retrying in {current_delay:.2f}s...")
                    
                    # Apply jitter
                    jitter_amount = current_delay * jitter
                    sleep_time = current_delay + random.uniform(-jitter_amount, jitter_amount)
                    sleep_time = max(0, min(sleep_time, max_delay)) # Ensure sleep_time is non-negative and within max_delay

                    time.sleep(sleep_time)
                    current_delay = min(current_delay * backoff_factor, max_delay)
        return wrapper
    return decorator

# --- Real-world scenario example ---

class ExternalService:
    def __init__(self, name, reliability_rate=0.6):
        self.name = name
        self.reliability_rate = reliability_rate
        self.call_count = 0

    def call(self):
        self.call_count += 1
        if random.random() < self.reliability_rate:
            logging.info(f"[{self.name}] Call successful on attempt {self.call_count}")
            return f"Data from {self.name}"
        else:
            logging.error(f"[{self.name}] Call failed on attempt {self.call_count}")
            raise ConnectionError(f"Failed to connect to {self.name}")

    def reset_call_count(self):
        self.call_count = 0

Fault Tolerant Systems: Circuit Breakers, Retries, Bulkheads
Generated Image
@retry(max_retries=5, initial_delay=0.1, max_delay=2, backoff_factor=2, jitter=0.2) def fetch_user_profile(user_id, service: ExternalService): logging.info(f"Attempting to fetch user {user_id} from {service.name}...") return service.call() @retry(max_retries=2, initial_delay=0.05, backoff_factor=3) def process_payment(transaction_id, service: ExternalService): # This might be a more critical operation, so fewer retries might be desired logging.info(f"Attempting to process payment {transaction_id} via {service.name}...") return service.call() if __name__ == "__main__": profile_service = ExternalService("UserProfileService", reliability_rate=0.4) payment_gateway = ExternalService("PaymentGateway", reliability_rate=0.7) print("\n--- Testing User Profile Fetch (more retries allowed) ---") try: data = fetch_user_profile("user123", profile_service) print(f"Successfully fetched: {data}") except ConnectionError: print("Failed to fetch user profile after multiple retries.") profile_service.reset_call_count() print("\n--- Testing Payment Processing (fewer retries allowed) ---") try: data = process_payment("txn456", payment_gateway) print(f"Successfully processed: {data}") except ConnectionError: print("Failed to process payment after multiple retries.") payment_gateway.reset_call_count() print("\n--- Testing a consistently failing service ---") failing_service = ExternalService("CriticalDataService", reliability_rate=0.0) try: data = fetch_user_profile("data999", failing_service) print(f"Successfully fetched: {data}") except ConnectionError: print("Failed to fetch critical data after multiple retries as expected.") failing_service.reset_call_count()

In this example, the @retry decorator encapsulates the retry logic. It allows us to specify the maximum number of retries, the initial delay, and how that delay should increase. The ExternalService class simulates an unreliable external dependency. Notice how different functions can have different retry configurations based on their criticality and expected failure modes.

Drawbacks of the Retry Pattern

  • Can Worsen Problems: If the downstream service is genuinely overloaded or down for a prolonged period, continuous retries from many clients will just add to the load, preventing recovery.
  • Increased Latency: Each retry adds delay to the overall operation.
  • Complexity: Needs careful configuration (backoff, jitter, retryable errors) to be effective.

This is where the Circuit Breaker pattern becomes essential.

The Circuit Breaker Pattern: Knowing When to Give Up

What is the Circuit Breaker Pattern?

Inspired by electrical circuit breakers, this pattern prevents a system from repeatedly trying to execute an operation that is likely to fail. Just as an electrical circuit breaker trips to prevent damage from an overload, a software circuit breaker “trips” (opens) when a service or operation fails repeatedly, preventing further calls to that failing component. This gives the failing service time to recover and prevents the calling service from wasting resources on doomed operations.

The Circuit Breaker pattern acts as a proxy for operations that might fail. It monitors the failure rate and, if a threshold is exceeded, it “opens” the circuit, causing all subsequent calls to fail immediately without even attempting to invoke the underlying operation. After a configurable period, it transitions to a “half-open” state to cautiously test if the underlying service has recovered.

States of a Circuit Breaker

A circuit breaker typically operates in three states:

  1. Closed: This is the default state. Requests are allowed to pass through to the underlying operation. The circuit breaker monitors the success/failure rate. If the failure rate exceeds a predefined threshold within a certain time window, the circuit breaker trips and transitions to the Open state.

  2. Open: In this state, requests to the underlying operation are immediately rejected (fail fast), usually by throwing an exception or returning a predefined error response, without even attempting to execute the operation. This prevents further load on the failing service and saves resources for the calling service. After a specified timeout (the “reset timeout”), the circuit breaker transitions to the Half-Open state.

  3. Half-Open: In this state, a limited number of test requests are allowed to pass through to the underlying operation. If these test requests succeed, it’s an indication that the service might have recovered, and the circuit breaker transitions back to the Closed state. If these test requests fail, it indicates the service is still unhealthy, and the circuit breaker transitions back to the Open state, restarting the reset timeout.

Interaction with Retries

Circuit breakers and retries are often used together. A retry mechanism can be placed before a circuit breaker. If a call fails with a transient error, the retry attempts to succeed. If repeated retries still fail, that’s when the circuit breaker logic kicks in to open the circuit. Once the circuit is open, subsequent requests will fail fast due to the circuit breaker, bypassing any retry logic until the circuit closes again.

Code Example: A Simple Circuit Breaker Implementation

Here’s a basic Python implementation of a Circuit Breaker.

import time
import threading
import logging
from functools import wraps

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

class CircuitBreakerError(Exception):
    """Custom exception raised when the circuit is open."""
    pass

class CircuitBreaker:
    def __init__(self, failure_threshold=3, recovery_timeout=5, test_attempts=1):
        """
        Initializes a Circuit Breaker.

        :param failure_threshold: Number of consecutive failures to trip the circuit.
        :param recovery_timeout: Time in seconds to wait in 'Open' state before transitioning to 'Half-Open'.
        :param test_attempts: Number of successful calls needed in 'Half-Open' to transition to 'Closed'.
        """
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.test_attempts = test_attempts

        self.state = "CLOSED"
        self.failures = 0
        self.last_failure_time = None
        self.successful_tests = 0
        self.lock = threading.Lock() # For thread-safe state transitions

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            with self.lock:
                if self.state == "OPEN":
                    if time.monotonic() - self.last_failure_time > self.recovery_timeout:
                        self.state = "HALF_OPEN"
                        self.successful_tests = 0
                        logging.warning(f"Circuit for {func.__name__} is now HALF_OPEN.")
                    else:
                        logging.error(f"Circuit for {func.__name__} is OPEN. Failing fast.")
                        raise CircuitBreakerError(f"Circuit for {func.__name__} is OPEN")
                
                elif self.state == "HALF_OPEN":
                    # Allow one test call
                    try:
                        result = func(*args, **kwargs)
                        # Success in half-open state
                        self.successful_tests += 1
                        logging.info(f"Test call successful ({self.successful_tests}/{self.test_attempts}) in HALF_OPEN for {func.__name__}.")
                        if self.successful_tests >= self.test_attempts:
                            self._reset_circuit()
                            logging.info(f"Circuit for {func.__name__} is now CLOSED after successful tests.")
                        return result
                    except Exception as e:
                        # Failure in half-open state
                        logging.warning(f"Test call failed in HALF_OPEN for {func.__name__}. Error: {e}. Re-opening circuit.")
                        self._trip_circuit() # Trip back to open
                        raise CircuitBreakerError(f"Circuit for {func.__name__} is OPEN after half-open failure") from e
                
                # If state is CLOSED
                try:
                    result = func(*args, **kwargs)
                    self.failures = 0 # Reset failures on success
                    return result
                except Exception as e:
                    self.failures += 1
                    logging.warning(f"Call to {func.__name__} failed ({self.failures}/{self.failure_threshold}). Error: {e}")
                    if self.failures >= self.failure_threshold:
                        self._trip_circuit()
                        logging.error(f"Circuit for {func.__name__} is now OPEN after {self.failure_threshold} failures.")
                        raise CircuitBreakerError(f"Circuit for {func.__name__} is OPEN") from e
                    raise # Re-raise the original exception if not yet tripped
        return wrapper

    def _trip_circuit(self):
        self.state = "OPEN"
        self.last_failure_time = time.monotonic()
        self.failures = 0 # Reset failure count when opening circuit
        self.successful_tests = 0

    def _reset_circuit(self):
        self.state = "CLOSED"
        self.failures = 0
        self.last_failure_time = None
        self.successful_tests = 0

# --- Real-world scenario example ---

# Simulate an external service that fails often then recovers
class UnreliableService:
    def __init__(self, name, fail_count_before_recovery=5, recovery_after_calls=3):
        self.name = name
        self.current_failures = 0
        self.total_calls = 0
        self.fail_count_before_recovery = fail_count_before_recovery
        self.recovery_after_calls = recovery_after_calls
        self.is_healthy = True

    def call(self):
        self.total_calls += 1
        if self.is_healthy:
            if self.current_failures < self.fail_count_before_recovery:
                self.current_failures += 1
                logging.debug(f"[{self.name}] Simulating failure {self.current_failures}/{self.fail_count_before_recovery}")
                if self.current_failures == self.fail_count_before_recovery:
                    self.is_healthy = False # Service becomes unhealthy after reaching fail_count
                raise ConnectionError(f"Simulated failure from {self.name}")
            else:
                logging.info(f"[{self.name}] Call successful (recovered).")
                return f"Data from {self.name}"
        else: # Unhealthy state, simulate recovery
            if self.current_failures > 0: # Still in recovery phase
                 self.current_failures -= 1
                 logging.debug(f"[{self.name}] Still unhealthy, remaining failures to simulate: {self.current_failures}")
                 raise ConnectionError(f"Simulated failure from {self.name}")
            else:
                self.is_healthy = True # Fully recovered
                logging.info(f"[{self.name}] Call successful (fully recovered).")
                return f"Data from {self.name}"

    def reset(self):
        self.current_failures = 0
        self.total_calls = 0
        self.is_healthy = True

# --- Application of Circuit Breaker ---

# Instantiate a circuit breaker for a critical service
critical_service_cb = CircuitBreaker(failure_threshold=3, recovery_timeout=10, test_attempts=1)
unreliable_api = UnreliableService("CriticalAPI", fail_count_before_recovery=4, recovery_after_calls=2)

@critical_service_cb
def call_critical_api():
    return unreliable_api.call()

if __name__ == "__main__":
    print("\n--- Circuit Breaker Demonstration ---")
    
    # Simulate repeated calls
    for i in range(1, 15):
        print(f"\nAttempt {i}:")
        try:
            result = call_critical_api()
            print(f"Success: {result}")
        except CircuitBreakerError as e:
            print(f"Circuit Breaker blocked call: {e}")
        except ConnectionError as e:
            print(f"Direct connection error: {e}")
        time.sleep(0.5) # Simulate some interval between calls
    
    unreliable_api.reset()
    critical_service_cb._reset_circuit() # Reset for another run

    print("\n--- Another scenario: Service recovers quickly ---")
    unreliable_api_fast_recovery = UnreliableService("FastRecoveryAPI", fail_count_before_recovery=2, recovery_after_calls=1)
    fast_cb = CircuitBreaker(failure_threshold=2, recovery_timeout=5, test_attempts=1)

    @fast_cb
    def call_fast_recovery_api():
        return unreliable_api_fast_recovery.call()

    for i in range(1, 10):
        print(f"\nAttempt {i}:")
        try:
            result = call_fast_recovery_api()
            print(f"Success: {result}")
        except CircuitBreakerError as e:
            print(f"Circuit Breaker blocked call: {e}")
        except ConnectionError as e:
            print(f"Direct connection error: {e}")
        time.sleep(1) # Longer sleep to allow recovery timeout to pass

This implementation provides a clear demonstration of the state transitions. When call_critical_api fails repeatedly, the circuit opens, preventing further calls. After the recovery_timeout, it cautiously transitions to half-open, sending a single request to test the service’s health. If successful, it closes; if not, it re-opens.

Benefits of the Circuit Breaker Pattern

  • Fail Fast: Prevents wasting resources on operations that are likely to fail.
  • Prevents Cascading Failures: By isolating a failing component, it prevents its failures from propagating upstream.
  • Allows Recovery: Gives the failing service a chance to recover without being hammered by continuous requests.
  • Improved User Experience: Can enable fallback mechanisms or graceful degradation instead of indefinite waiting.

Drawbacks of the Circuit Breaker Pattern

  • Complexity: Adds state management and logic to your application.
  • Tuning: Requires careful tuning of parameters (thresholds, timeouts) to be effective without being overly aggressive or too slow to react.

While circuit breakers protect against overloading a failing service, what if one part of your system starts consuming all resources, even if it’s not failing? This is where Bulkheads come in.

The Bulkhead Pattern: Isolating Failure Domains

What is the Bulkhead Pattern?

The Bulkhead pattern is inspired by the design of ships, which are divided into watertight compartments (bulkheads). If one compartment is breached and fills with water, the bulkheads prevent the entire ship from sinking by containing the damage to a single section. In software, the Bulkhead pattern isolates different parts of a system, or different types of requests, into separate resource pools. This prevents a failure or resource exhaustion in one part of the system from affecting others, thereby improving overall system resilience.

Unlike circuit breakers, which deal with the failure of a specific operation or service, bulkheads deal with the overall resource consumption and capacity of different types of operations or dependencies within a service.

Implementation Strategies for Bulkheads

Bulkheads are typically implemented by partitioning resources based on:

  1. Thread/Process Pools: Assign distinct thread pools (or process pools) for different types of requests or calls to different external services. For example, a web service might use one thread pool for handling requests to a fast internal caching service and a separate, smaller thread pool for calls to a slow, external third-party API. If the external API becomes unresponsive, only its dedicated thread pool will be exhausted, leaving the thread pool for the cache service available to handle other requests.

  2. Semaphores: A semaphore can limit the number of concurrent calls to a particular resource or service. This is a lighter-weight alternative to thread pools for controlling concurrency.

  3. Separate Queues: For asynchronous processing, different types of messages can be put into separate queues. This prevents a backlog in one queue from blocking messages in another, more critical queue.

  4. Resource Partitioning (e.g., Database Connections): Allocating separate connection pools for different types of database operations or for different microservices accessing the same database.

Architectural Description: Bulkheads in a Microservice Context

Consider a frontend API Gateway that processes requests and forwards them to various backend microservices:

<

Written by

Khader Vali

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

Share this article

Related Articles

From Monolith to Microservices: A Practical Migration Guide

Sep 14, 2024 · 2 min read

Full Stack TypeScript: tRPC & Prisma End-to-End

May 26, 2026 · 10 min read

Event-Driven Architecture: When to Use It and When to Avoid It

Apr 28, 2026 · 2 min read