Skip to content

The Circuit Breaker Pattern: Failing Gracefully

When a downstream service is failing, continuing to send requests makes everything worse — the circuit breaker pattern stops the cascade by failing fast.

3 min read
Circuit breaker state machine showing closed, open, and half-open states

Your payment service is down. Every request to your API triggers a call to the payment service, which times out after 30 seconds. Your thread pool fills up. Your API becomes unresponsive. Users can't even browse products — a payment outage has cascaded into a total system failure. The circuit breaker pattern prevents this by monitoring failure rates and short-circuiting calls to failing services, returning an error immediately instead of waiting for a timeout.

The Three States

A circuit breaker has three states: closed (normal operation, requests pass through), open (service is failing, requests are rejected immediately), and half-open (testing if the service has recovered).

tstypescript
type CircuitState = "closed" | "open" | "half-open";
 
class CircuitBreaker<T> {
  private state: CircuitState = "closed";
  private failureCount = 0;
  private successCount = 0;
  private lastFailureTime = 0;
 
  constructor(
    private readonly fn: () => Promise<T>,
    private readonly options: {
      failureThreshold: number;   // Failures before opening
      resetTimeout: number;       // Ms before trying half-open
      halfOpenRequests: number;   // Successful requests to close
    }
  ) {}
 
  async execute(): Promise<T> {
    if (this.state === "open") {
      if (Date.now() - this.lastFailureTime > this.options.resetTimeout) {
        this.state = "half-open";
        this.successCount = 0;
      } else {
        throw new CircuitOpenError("Circuit breaker is open");
      }
    }
 
    try {
      const result = await this.fn();
 
      if (this.state === "half-open") {
        this.successCount++;
        if (this.successCount >= this.options.halfOpenRequests) {
          this.state = "closed";
          this.failureCount = 0;
        }
      } else {
        this.failureCount = 0;
      }
 
      return result;
    } catch (error) {
      this.failureCount++;
      this.lastFailureTime = Date.now();
 
      if (this.failureCount >= this.options.failureThreshold) {
        this.state = "open";
      }
 
      throw error;
    }
  }
 
  getState(): CircuitState {
    return this.state;
  }
}
 
class CircuitOpenError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "CircuitOpenError";
  }
}

Using the Circuit Breaker

Wrap any fallible external call with a circuit breaker. When the circuit opens, the caller gets an immediate error instead of waiting for a timeout.

tstypescript
// ❌ Direct call — 30s timeout when payment service is down
async function createOrder(order: OrderData): Promise<OrderResult> {
  const payment = await fetch("http://payment-service/charge", {
    method: "POST",
    body: JSON.stringify({ amount: order.total }),
    signal: AbortSignal.timeout(30000), // 30 seconds of waiting
  });
  return payment.json();
}
 
// ✅ Circuit breaker — fails immediately when service is known to be down
const paymentBreaker = new CircuitBreaker(
  () => fetch("http://payment-service/charge", {
    method: "POST",
    body: JSON.stringify({ amount: order.total }),
    signal: AbortSignal.timeout(5000),
  }).then(r => r.json()),
  {
    failureThreshold: 5,      // Open after 5 consecutive failures
    resetTimeout: 30000,       // Try again after 30 seconds
    halfOpenRequests: 3,       // Need 3 successes to fully close
  }
);
 
async function createOrder(order: OrderData): Promise<OrderResult> {
  try {
    return await paymentBreaker.execute();
  } catch (error) {
    if (error instanceof CircuitOpenError) {
      // Fail fast — return a meaningful response
      return {
        status: "pending",
        message: "Payment processing is temporarily unavailable. Your order has been saved and will be processed shortly.",
      };
    }
    throw error;
  }
}

Fallback Strategies

When the circuit opens, you have options beyond just returning an error.

tstypescript
// Strategy 1: Cached response
async function getProductPrice(productId: string): Promise<number> {
  try {
    return await pricingBreaker.execute();
  } catch (error) {
    if (error instanceof CircuitOpenError) {
      // Return cached price
      const cached = await cache.get(`price:${productId}`);
      if (cached) return Number(cached);
    }
    throw error;
  }
}
 
// Strategy 2: Queue for retry
async function processPayment(order: OrderData): Promise<PaymentResult> {
  try {
    return await paymentBreaker.execute();
  } catch (error) {
    if (error instanceof CircuitOpenError) {
      // Queue for later processing
      await paymentQueue.enqueue({
        orderId: order.id,
        amount: order.total,
        retryAfter: Date.now() + 60000,
      });
      return { status: "queued", message: "Payment will be processed when service recovers" };
    }
    throw error;
  }
}
 
// Strategy 3: Degraded response
async function getRecommendations(userId: string): Promise<Product[]> {
  try {
    return await recommendationBreaker.execute();
  } catch (error) {
    if (error instanceof CircuitOpenError) {
      // Return popular products instead of personalized recommendations
      return await getPopularProducts();
    }
    throw error;
  }
}

Monitoring Circuit Breaker State

Expose circuit breaker metrics for dashboards and alerting.

tstypescript
import { Counter, Gauge } from "prom-client";
 
const circuitStateGauge = new Gauge({
  name: "circuit_breaker_state",
  help: "Current state of circuit breaker (0=closed, 1=open, 2=half-open)",
  labelNames: ["service"],
});
 
const circuitTripsCounter = new Counter({
  name: "circuit_breaker_trips_total",
  help: "Number of times the circuit breaker has opened",
  labelNames: ["service"],
});
 
// Enhanced circuit breaker with metrics
class MonitoredCircuitBreaker<T> extends CircuitBreaker<T> {
  constructor(
    fn: () => Promise<T>,
    options: CircuitBreakerOptions,
    private readonly serviceName: string
  ) {
    super(fn, options);
  }
 
  async execute(): Promise<T> {
    const stateMap = { closed: 0, open: 1, "half-open": 2 };
    circuitStateGauge.set({ service: this.serviceName }, stateMap[this.getState()]);
 
    try {
      return await super.execute();
    } catch (error) {
      if (this.getState() === "open") {
        circuitTripsCounter.inc({ service: this.serviceName });
      }
      throw error;
    }
  }
}

Configuring Thresholds

Circuit breaker thresholds depend on the service's expected behavior and your tolerance for failures.

tstypescript
// Low-latency, high-reliability service (payment processing)
const paymentBreaker = new CircuitBreaker(paymentCall, {
  failureThreshold: 3,        // Open quickly — payments are critical
  resetTimeout: 15000,         // Retry soon
  halfOpenRequests: 5,         // Require more successes to restore trust
});
 
// High-latency, best-effort service (recommendations)
const recommendationBreaker = new CircuitBreaker(recommendationCall, {
  failureThreshold: 10,        // More tolerant — recommendations are optional
  resetTimeout: 60000,          // Check less frequently
  halfOpenRequests: 2,          // Fewer successes needed
});

Key Takeaways

  1. Circuit breakers prevent cascade failures — failing fast is better than tying up resources waiting for timeouts
  2. Three states govern behavior — closed passes through, open rejects immediately, half-open tests recovery
  3. Design meaningful fallbacks — cached data, queued retries, or degraded responses are better than errors
  4. Monitor circuit state — an open circuit is an operational signal that needs attention
  5. Tune thresholds per service — critical services should trip fast, optional services can be more tolerant
  6. Combine with timeouts and retries — circuit breakers complement (don't replace) request-level timeout configuration
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX