System Design

Designing Robust Systems: Circuit Breakers, Retries & Bulkheads

Master fault-tolerant system design. Learn about circuit breakers, retries (with exponential backoff), and bulkheads to build robust, resilient distributed applications.

Khader Vali September 27, 2026 17 min read

In the vast, interconnected landscape of modern software, where monoliths have fragmented into microservices and applications rely on a myriad of external dependencies, the illusion of perfect uptime is a dangerous one. Every network call, every database query, every third-party API interaction carries the inherent risk of failure. As senior engineers, our challenge isn’t to prevent failures—that’s often impossible—but to design systems that anticipate, gracefully handle, and recover from them. This is the essence of fault tolerance.

At Khadervali.com, we believe in building robust, resilient systems that can withstand the inevitable chaos of production environments. In this comprehensive guide, we’ll dive deep into three foundational patterns critical for achieving fault tolerance in distributed systems: Retries, Circuit Breakers, and Bulkheads. We’ll explore their individual mechanisms, demonstrate their implementation with practical code examples, and discuss how to combine them for a truly resilient architecture.

Designing Robust Systems: Circuit Breakers, Retries & Bulkheads

The journey to fault tolerance begins with a fundamental shift in mindset: assume everything will fail, eventually.

The Fragility of Distributed Systems

Gone are the days when a single server failure brought down a monolithic application entirely. While microservices offer agility and scalability, they introduce a new dimension of complexity: the network. Network partitions, latency spikes, slow responses, and outright connection failures are constant threats. When one service in a chain becomes unhealthy, it can quickly trigger a cascade of failures, bringing down an entire application, even if the root cause was a minor, transient issue in a single component.

Consider a typical e-commerce transaction: A user requests a product page. This might involve:

  1. Your authentication service verifying the user.
  2. Your product catalog service fetching product details.
  3. Your inventory service checking stock levels.
  4. Your recommendations service suggesting related items.
  5. Your pricing service calculating the final price.

Each of these steps is a network call, a potential point of failure. If the inventory service is momentarily overloaded or slow, what happens? Without proper fault-tolerant mechanisms, the product page might hang, the request might time out, or worse, your entire system could become unresponsive as other services wait indefinitely for the inventory service to respond.

The Foundation: Fault Tolerance and Resilience

Fault tolerance refers to a system’s ability to continue operating, perhaps in a degraded mode, even when one or more of its components fail. It’s about enduring failures rather than preventing them entirely.

Resilience is a broader term, encompassing fault tolerance but also adding the ability to recover quickly from failures and adapt to changing conditions. A resilient system not only survives a component failure but can also restore full functionality efficiently.

The primary goals of designing for fault tolerance are:

  • Preventing Cascading Failures: Stop a small issue from becoming a system-wide outage.
  • Graceful Degradation: Maintain core functionality even when non-critical components are struggling.
  • Maintaining Service Quality: Ensure a reasonable user experience despite underlying issues.
  • Fast Recovery: Enable components to recover and reintegrate into the system quickly.

Now, let’s explore the practical patterns that help us achieve these goals.

Pattern 1: Retries

Retries are perhaps the simplest and most intuitive fault tolerance mechanism. When an operation fails, you simply try it again. While seemingly straightforward, the devil is in the details, and improper use of retries can often exacerbate problems rather than solve them.

What are Retries?

Retries involve re-attempting a failed operation. They are most effective for transient errors—those that are temporary and likely to resolve themselves quickly. Examples include:

  • Network glitches (e.g., a momentary connection drop).
  • Temporary resource unavailability (e.g., a database lock, a service temporarily overloaded).
  • Optimistic concurrency control failures.

Retries are NOT suitable for permanent errors (e.g., invalid authentication credentials, a non-existent resource, a logic error in the code) as they will simply fail repeatedly, wasting resources and potentially adding load to a struggling service.

Simple Retries

A basic retry mechanism involves a loop that re-executes the operation a fixed number of times upon failure.

import time

def call_external_service_simple(max_retries=3):
    for i in range(max_retries + 1):
        try:
            print(f"Attempt {i+1} to call external service...")
            # Simulate a transient failure
            if i < max_retries: # Fail for the first 'max_retries' attempts
                raise ConnectionError("Service temporarily unavailable")
            print("External service call successful!")
            return "Data from service"
        except ConnectionError as e:
            print(f"Attempt {i+1} failed: {e}")
            if i == max_retries:
                print("Max retries reached. Giving up.")
                raise
            time.sleep(1) # Wait before retrying

# Example usage:
try:
    result = call_external_service_simple()
    print(f"Final result: {result}")
except ConnectionError:
    print("Failed to get data after multiple retries.")

This simple approach has a significant flaw: if many clients retry simultaneously after a shared failure (like a service restart), they can create a "thundering herd" problem, overwhelming the recovering service and preventing it from fully recovering. This is where backoff strategies come into play.

Exponential Backoff

Exponential backoff is a technique where the delay between retries increases exponentially with each subsequent attempt. This helps to spread out the retry attempts over time, reducing the load on the failing service and giving it a chance to recover. It's a cornerstone of robust retry policies.

import time
import random

def call_external_service_exponential_backoff(max_retries=5, base_delay_seconds=1):
    for i in range(max_retries + 1):
        try:
            print(f"Attempt {i+1} to call external service...")
            # Simulate a transient failure
            if i < max_retries - 2: # Fail for a few attempts
                raise ConnectionError("Service temporarily unavailable")
            print("External service call successful!")
            return "Data from service"
        except ConnectionError as e:
            print(f"Attempt {i+1} failed: {e}")
            if i == max_retries:
                print("Max retries reached. Giving up.")
                raise
            
            delay = base_delay_seconds * (2 ** i) # Exponential increase
            print(f"Waiting {delay:.2f} seconds before next retry...")
            time.sleep(delay)

# Example usage:
try:
    result = call_external_service_exponential_backoff()
    print(f"Final result: {result}")
except ConnectionError:
    print("Failed to get data after multiple retries with exponential backoff.")

In this example, the delay sequence would be approximately 1, 2, 4, 8, 16 seconds. This gives the downstream service significantly more breathing room.

Jitter

Even with exponential backoff, if many clients encounter the same failure simultaneously, their backoff timers might align, leading to synchronized retries. To mitigate this, we introduce jitter, which adds a small, random variation to the calculated delay. This further disperses the retry attempts, preventing synchronized "waves" of requests.

import time
import random

def call_external_service_with_jitter(max_retries=5, base_delay_seconds=1, max_jitter_factor=0.5):
    for i in range(max_retries + 1):
        try:
            print(f"Attempt {i+1} to call external service...")
            # Simulate a transient failure
            if i < max_retries - 2:
                raise ConnectionError("Service temporarily unavailable")
            print("External service call successful!")
            return "Data from service"
        except ConnectionError as e:
            print(f"Attempt {i+1} failed: {e}")
            if i == max_retries:
                print("Max retries reached. Giving up.")
                raise
            
            base_delay = base_delay_seconds * (2 ** i)
            jitter = random.uniform(-max_jitter_factor * base_delay, max_jitter_factor * base_delay)
            delay = max(0, base_delay + jitter) # Ensure delay is not negative
            
            print(f"Waiting {delay:.2f} seconds (with jitter) before next retry...")
            time.sleep(delay)

# Example usage:
try:
    result = call_external_service_with_jitter()
    print(f"Final result: {result}")
except ConnectionError:
    print("Failed after multiple retries with exponential backoff and jitter.")

A common strategy is "full jitter," where the delay is chosen randomly between 0 and the exponential backoff value, or "equal jitter," which combines a fixed part with a random part. The key is to introduce sufficient randomness.

Idempotency: A Critical Consideration

When implementing retries, especially for operations that modify state, idempotency is paramount. An idempotent operation is one that can be applied multiple times without changing the result beyond the initial application. For example, setting a value is idempotent, but incrementing a counter is not.

  • Idempotent: SET user.status = 'active' (calling this multiple times has the same effect as calling it once).
  • Not Idempotent: INCREMENT user.balance BY 10 (calling this multiple times will increment the balance multiple times).

If a non-idempotent operation fails after the remote service has actually processed it but before you receive a success response, retrying it can lead to unintended side effects (e.g., duplicate orders, double charges). For non-idempotent operations, you often need a more sophisticated mechanism, like transaction IDs or relying on the downstream service to handle idempotency.

Retry Limits and Timeouts

Even with backoff and jitter, retries must have sensible limits. An infinite retry loop can consume resources indefinitely, leading to memory exhaustion or cascading resource starvation. Always define:

  • Maximum number of retries: After this many attempts, the operation should fail permanently.
  • Maximum total time: A cumulative timeout for all retry attempts. This prevents long-running operations from tying up resources for too long.

Real-world Scenarios for Retries:

  • API Calls: Calling a third-party API that occasionally experiences brief network hiccups.
  • Database Connections: Reconnecting to a database after a momentary network interruption or during a failover event.
  • Message Queue Producers: Retrying to send a message when the queue is temporarily full or unavailable.

<

Designing Robust Systems: Circuit Breakers, Retries & Bulkheads
Generated Image

>

Pattern 2: Circuit Breakers

While retries are excellent for transient failures, they can be detrimental when dealing with persistent or widespread issues. Continuously retrying a service that is genuinely down or severely overloaded will only exacerbate its problems, prolong its recovery, and consume valuable resources on the client side. This is where the Circuit Breaker pattern comes in.

The Problem Circuit Breakers Solve

Imagine a scenario where Service A calls Service B. If Service B becomes unresponsive, Service A's requests to B will start timing out. If Service A simply retries, it will continue to send requests to a failing Service B, further overwhelming it and tying up its own resources (threads, connections) waiting for timeouts. This can lead to Service A becoming unresponsive itself, eventually cascading the failure upstream.

A circuit breaker acts like an electrical circuit breaker: when an appliance (the downstream service) draws too much current (experiences too many failures), the breaker trips, opening the circuit and preventing further damage.

How Circuit Breakers Work: The State Machine

The circuit breaker pattern wraps calls to a protected function (e.g., an external service call) and monitors for failures. It operates using a state machine, typically having three main states:

  1. Closed: The default state. Requests are allowed to pass through to the protected function. The circuit breaker monitors for failures. If the failure rate (or number of consecutive failures) exceeds a predefined threshold, the circuit trips to the Open state.
  2. Open: When the circuit is open, all requests to the protected function fail immediately without attempting to execute the actual operation. Instead, they return a predefined error or fallback response. After a configurable timeout period, the circuit 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 protected function. If these test requests succeed, the circuit assumes the downstream service has recovered and transitions back to the Closed state. If they fail, it assumes the service is still unhealthy and transitions back to the Open state (resetting the timeout).

Here's a textual representation of the state transitions:

+-----------------+      Failure threshold exceeded    +-----------------+
|     CLOSED      | --------------------------------> |      OPEN       |
| (Normal Ops)    |                                   | (Fail Fast)     |
|   - Monitor Fails                                   |   - After Timeout |
|   - If Fails > Threshold, Go to OPEN                |   - Go to HALF-OPEN |
+-----------------+ <-------------------------------- +-----------------+
       ^                                                 |
       |                                                 | Test Request Fails
       | Test Requests Succeed                           |
       |                                                 |
       +-----------------+ <-----------------------------+
       |   HALF-OPEN     |
       | (Probing)       |
       |   - Allow Limited Requests                      |
       |   - If Success, Go to CLOSED                    |
       |   - If Fail, Go to OPEN                         |
       +-----------------+

Configuration Parameters

Key parameters for configuring a circuit breaker:

  • Failure Threshold: The number of consecutive failures or the percentage of failures within a time window that will trip the circuit to Open.
  • Open Timeout (or Sleep Window): The duration the circuit stays in the Open state before transitioning to Half-Open.
  • Success Threshold (for Half-Open): The number of successful requests required in the Half-Open state to transition back to Closed.

Implementation Example (Conceptual)

While libraries like Hystrix (Java, now in maintenance mode) or Polly (.NET) provide robust implementations, understanding the core logic is key. Here’s a simplified Python-like representation.

import time
import threading

class CircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout_seconds=60, half_open_success_threshold=3):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout_seconds
        self.half_open_success_threshold = half_open_success_threshold

        self.state = "CLOSED"
        self.failure_count = 0
        self.last_failure_time = 0
        self.success_count_half_open = 0
        self.lock = threading.Lock() # Protect state transitions

    def _call_service(self, func, *args, **kwargs):
        # This is the actual call to the protected service
        return func(*args, **kwargs)

    def execute(self, func, *args, **kwargs):
        with self.lock:
            if self.state == "OPEN":
                if time.time() - self.last_failure_time > self.recovery_timeout:
                    print("Circuit Breaker: Timeout elapsed, moving to HALF-OPEN.")
                    self.state = "HALF-OPEN"
                    self.success_count_half_open = 0 # Reset success counter for half-open
                else:
                    print(f"Circuit Breaker: OPEN. Failing fast for '{func.__name__}'.")
                    raise CircuitBreakerOpenError("Circuit is OPEN, service is likely down.")

            if self.state == "HALF-OPEN":
                try:
                    result = self._call_service(func, *args, **kwargs)
                    self.success_count_half_open += 1
                    print(f"Circuit Breaker: HALF-OPEN. Test call successful ({self.success_count_half_open}/{self.half_open_success_threshold}).")
                    if self.success_count_half_open >= self.half_open_success_threshold:
                        print(f"Circuit Breaker: Achieved {self.half_open_success_threshold} successes. Moving to CLOSED.")
                        self.state = "CLOSED"
                        self.failure_count = 0
                    return result
                except Exception as e:
                    print(f"Circuit Breaker: HALF-OPEN. Test call failed. Moving back to OPEN. Error: {e}")
                    self.state = "OPEN"
                    self.last_failure_time = time.time()
                    raise CircuitBreakerOpenError("Circuit is OPEN after half-open failure.") from e

            if self.state == "CLOSED":
                try:
                    result = self._call_service(func, *args, **kwargs)
                    # Reset failure count on success
                    self.failure_count = 0
                    return result
                except Exception as e:
                    self.failure_count += 1
                    print(f"Circuit Breaker: CLOSED. Call failed ({self.failure_count}/{self.failure_threshold}). Error: {e}")
                    if self.failure_count >= self.failure_threshold:
                        print("Circuit Breaker: Failure threshold reached. Moving to OPEN.")
                        self.state = "OPEN"
                        self.last_failure_time = time.time()
                    raise # Re-raise the original exception

class CircuitBreakerOpenError(Exception):
    pass

# --- Example Usage ---
def unreliable_service():
    """Simulates a service that fails for a while, then recovers."""
    if unreliable_service.calls_until_recovery > 0:
        unreliable_service.calls_until_recovery -= 1
        raise ConnectionError("Service is down!")
    print("Service is healthy!")
    return "Service Data"

unreliable_service.calls_until_recovery = 7 # Fails 7 times then recovers

cb = CircuitBreaker(failure_threshold=3, recovery_timeout_seconds=10, half_open_success_threshold=2)

print("\n--- Initial calls (expecting failures) ---")
for _ in range(10):
    try:
        cb.execute(unreliable_service)
        time.sleep(0.5)
    except (ConnectionError, CircuitBreakerOpenError) as e:
        print(f"Caught expected error: {e}")
        time.sleep(0.5) # Wait a bit before next call

print("\n--- Waiting for recovery timeout ---")
time.sleep(11) # Wait longer than recovery_timeout_seconds

print("\n--- Calls after recovery timeout (expecting half-open then closed) ---")
for _ in range(5):
    try:
        cb.execute(unreliable_service)
        time.sleep(0.5)
    except (ConnectionError, CircuitBreakerOpenError) as e:
        print(f"Caught expected error: {e}")
        time.sleep(0.5)

This conceptual code illustrates the state transitions. In a real-world scenario, you'd use a battle-tested library or framework that handles concurrency, metrics, and more sophisticated failure tracking (e.g., rolling window for failure rates).

Integration with Retries

Circuit breakers and retries are often used together but must be layered carefully. Generally:

  • Retries first, then Circuit Breaker: For transient errors, apply a few retries (with exponential backoff). If these retries consistently fail, then the circuit breaker should trip.
  • Circuit Breaker handles persistent failures: Once the circuit is open, no more retries should be attempted until the circuit transitions to Half-Open. The circuit breaker's "fail-fast" behavior prevents wasted retries.

Think of it as: "Is this a temporary blip? Retry a few times. Is this service consistently failing? Stop trying altogether for a while using a circuit breaker."

Real-world Scenarios for Circuit Breakers:

  • Microservice Communication: Preventing a slow or unresponsive downstream microservice from taking down upstream services.
  • External API Integrations: Protecting your application from unreliable third-party APIs (payment gateways, shipping providers, social media integrations).
  • Database Load: Preventing an application from hammering an overloaded database with connection attempts.

Pattern 3: Bulkheads

Even with retries and circuit breakers, a single failing component can still exhaust shared resources within your own application, leading to a system-wide meltdown. This is where the Bulkhead pattern comes into play, providing isolation and preventing resource starvation.

The Analogy: Ship Compartments

The name "bulkhead" comes from naval architecture. Ships are often designed with watertight compartments (bulkheads). If one compartment is breached, it can be sealed off, preventing the entire ship from flooding. In software, bulkheads apply the same principle: isolate components or resource pools so that a failure or overload in one doesn't sink the entire application.

What Bulkheads Do

Bulkheads isolate different types of resource consumers (e.g., calls to different external services, different types of users) by allocating separate, limited resource pools for each. If one consumer experiences issues and exhausts its allocated resources, it won't impact other consumers.

Types of Bulkheads

1. Thread Pool Bulkheads

This is a common implementation, especially in environments like Java with servlet containers. Instead of using a single global thread pool for all outgoing calls, you dedicate separate, fixed-size thread pools for different types of dependencies. If calls to Service A start hanging, only the thread pool for Service A will become saturated, leaving threads available for calls to Service B, Service C, and for handling incoming requests.

Architecture Description (in words):

Imagine a central API Gateway service that handles incoming requests. This gateway needs to call three backend microservices: UserService, ProductService, and PaymentService. Without bulkheads, all these calls might share a single thread pool. If PaymentService becomes slow and its calls start hanging, the shared thread pool could quickly exhaust all its threads, making the API Gateway unresponsive even to requests that only need UserService or ProductService.

With thread pool bulkheads, the API Gateway would maintain three separate thread pools:

  • UserServiceThreadPool (e.g., 20 threads)
  • ProductServiceThreadPool (e.g., 20 threads)
  • PaymentServiceThreadPool (e.g., 10 threads)

If PaymentService starts struggling, only the PaymentServiceThreadPool will become saturated. Calls to UserService and ProductService will continue to use their dedicated threads, ensuring the gateway remains responsive for those operations. Calls to the overloaded PaymentService will quickly hit the thread pool limit and fail fast, preventing them from consuming more resources.

2. Semaphore Bulkheads

Semaphore bulkheads limit the number of concurrent calls to a specific component or resource using a semaphore (a concurrency primitive). This is often lighter weight than dedicated thread pools and can be used to protect a single resource or endpoint within a service.

import threading
import time
import random

class ServiceBulkhead:
def __init__(self, capacity=5, service_name="DefaultService"):
self.capacity = capacity
self.semaphore = threading.Semaphore(capacity)
self.service_name = service_name
self.active_requests = 0
self.lock = threading.Lock() # For active_requests counter

def call(self, task_duration=1):
with self.lock:
if self.active_requests >= self.capacity:
print(f"BULKHEAD ({self.service_name}): Capacity {self.capacity} reached. Failing fast.")
raise RuntimeError(f"Bulkhead for {self.service_name} is full.")
self.active_requests += 1

try:
acquired = self.semaphore.acquire(blocking=False) # Try to acquire without blocking
if not acquired:
# This path should ideally not be reached if active_requests check is perfect,
# but good for safety/double-check.
print(f"BULKHEAD ({self.service_name}): Semaphore not acquired. Capacity {self.capacity} reached. Failing fast.")
raise RuntimeError(f"Bulkhead for {self.service_name} is full.")

print(f"BULKHEAD ({self.service_name}): Request started. Active: {self.active_requests}/{self.capacity}")
time.sleep(task_duration + random.uniform(0, 0.2)) # Simulate work
print(f"BULKHEAD ({self.service_name}): Request finished. Active: {self.active_requests-1}/{self.capacity}")
return f"Result from {self.service_name}"
except RuntimeError as e:
raise e
finally:
with self.lock:
self.active_requests -= 1
if acquired: # Only release if acquired
self.semaphore.release()

# --- Example Usage ---
def simulate_client(bulkhead, client_id, num_calls=10, delay_between_calls=0.1):
for i in range(num_calls):
try:
print(f"Client {client_id}: Attempting call {i+1} to {bulkhead.service_name}...")
result = bulkhead.call(task_duration=0.5)
# print(f"Client {client_id}: {result}")
except RuntimeError as e:
print(f"Client {client_id}: Failed call {i+1} due to bulkhead: {e}")
time.sleep(delay_between_calls)

# Create two bulkheads for different services

Written by

Khader Vali

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

Share this article

Related Articles

Build Powerful CLI Developer Tools with Rust and Go

Oct 08, 2026 · 14 min read

Build Real-Time Streaming Pipelines with Kafka & Flink

Aug 10, 2026 · 15 min read

Rust & WebAssembly: Building Performant Web Apps

Oct 04, 2026 · 12 min read