Imagine you're at home and a power surge threatens to fry your appliances. What saves them? A circuit breaker - a simple device that cuts the power when it detects danger, protecting everything downstream from damage.
In software engineering, we face a similar problem. When a service your application depends on starts failing, those failures can cascade through your system, bringing everything down. The Circuit Breaker design pattern applies the same protective principle to software - it "trips" when failures are detected, preventing your application from repeatedly trying to execute an operation that's likely to fail.
The Problem: Cascading Failures
Consider a typical microservices architecture where Service A calls Service B, which calls Service C. What happens when Service C goes down?
Without protection,
- Service C fails to respond
- Service B waits for the timeout, holding resources
- Service A's requests pile up waiting for Service B
- Users experience slow responses or complete failures
- Eventually, the entire system grinds to a halt
This is a cascading failure - and it's one of the most common ways distributed systems fail catastrophically.
The Solution: Circuit Breaker Pattern
The Circuit Breaker pattern, popularized by Michael Nygard in his book Release It!, provides a elegant solution. Like its electrical namesake, it monitors for failures and "trips" when a threshold is reached, immediately failing subsequent requests instead of waiting for inevitable timeouts.
The Three States
A circuit breaker operates in three distinct states:
1. Closed State (Normal Operation)
The circuit is closed, and requests flow through normally. The breaker monitors for failures.
2. Open State (Failure Mode)
When failures exceed a threshold, the circuit "trips" open. All requests immediately fail without attempting the operation - this is called fail-fast behavior.
3. Half-Open State (Recovery Testing)
After a timeout period, the breaker allows a single test request through. If it succeeds, the circuit closes. If it fails, the circuit opens again.
Implementation Deep Dive
Let's examine a real-world TypeScript implementation of the Circuit Breaker pattern using the State design pattern:
The State Interface
Each state implements this interface, encapsulating state-specific behavior. This is a clean application of the State pattern - behavior changes based on internal state without complex conditionals.
export interface CircuitBreakerState {
name: string;
call<T>(action: () => Promise<T>): Promise<T>;
}
The Closed State
In the Closed state, every call passes through to the actual operation. Successes reset the failure count; failures increment it. When the failure threshold is reached, the breaker transitions to the Open state.
class CircuitBreakerClosedState implements CircuitBreakerState {
private circuitBreaker: CircuitBreaker;
constructor(circuitBreaker: CircuitBreaker) {
this.circuitBreaker = circuitBreaker;
}
async call<T>(action: () => Promise<T>): Promise<T> {
try {
const result = await action();
this.circuitBreaker.recordSuccess();
return result;
} catch (error) {
this.circuitBreaker.recordFailure();
throw error;
}
}
get name(): string {
return "CLOSED";
}
}
The Open State
The Open state is pretty simple - it rejects all calls immediately. No waiting for timeouts, no consuming resources. This fail-fast behavior is the key to preventing cascading failures.
class CircuitBreakerOpenState implements CircuitBreakerState {
async call<T>(action: () => Promise<T>): Promise<T> {
throw new Error("Circuit breaker is OPEN. Calls are not allowed.");
}
get name(): string {
return "OPEN";
}
}
The Half-Open State
The Half-Open state is the "test the waters" phase. A single request is allowed through:
- If the request succeeds, the circuit closes, normal operation resumes
- If the request fails, the circuit opens again, extending the protection period
The Circuit Breaker Core
export class CircuitBreaker {
private failureThreshold: number;
private recoveryTimeout: number;
private _failureCount: number;
private _state: CircuitBreakerState;
private timeoutHandler: NodeJS.Timeout | null = null;
constructor(options?: CircuitBreakerOptions) {
this._failureCount = 0;
this.failureThreshold = options?.failureThreshold ?? 5;
this.recoveryTimeout = options?.recoveryTimeout ?? 10000;
this._state = new CircuitBreakerClosedState(this);
}
// ... state transition methods
}
Circuit Breaker in Action
Here's a circuit breaker implementation for handling consequent failing calls to a CAS's serviceValidate endpoint:
// src/contexts/AuthContext.tsx
import { CircuitBreaker } from "@/lib/circuit-breaker";
import React, { useCallback, useRef } from "react";
// ... more imports
export function AuthProvider({
allowedHost,
children,
}: Readonly<{
allowedHost: string;
children: React.ReactNode;
}>) {
// ... more hooks
const loginCircuitBreakerRef = useRef(
new CircuitBreaker({ failureThreshold: 3, recoveryTimeout: 30000 }),
);
// ... more code
const login = useCallback(
async (ticket: string) => {
// ... more code here
let loginData;
try {
const validateUrl = new URL(
"/api/auth/validate",
window.location.origin,
);
validateUrl.searchParams.set("ticket", ticket);
// Leverage circuit breaker pattern to prevent
// sending consequent requests to a failing service
loginData = await loginCircuitBreakerRef.current.call(
async () => {
const loginRes = await createLoginResponse(validateUrl);
return await parseLoginResponse(loginRes);
},
);
} catch (err) {
// ... error handler here
} finally {
loadingRef.current = false;
}
// ... set new login data here
},
[createLoginResponse, parseLoginResponse, router],
);
// ... more code
}
Conclusion
The Circuit Breaker pattern is essential for building resilient distributed systems. By failing fast when a dependency is struggling, we prevent cascading failures from happening, preserve resources for requests that can succeed, and allow recovery time for struggling services.
Top comments (0)