Skip to content

Circuit Breaker

The circuit breaker pattern prevents cascading failures when an API goes down. Instead of repeatedly hitting a failing service, the circuit breaker "opens" and immediately rejects requests, giving the service time to recover.

Why Use a Circuit Breaker?

Without a circuit breaker, when a service fails:

  1. Every request waits for a timeout
  2. Your app becomes slow and unresponsive
  3. You waste resources on doomed requests
  4. The failing service gets overwhelmed with retry attempts

With a circuit breaker:

  1. After detecting failures, requests fail immediately
  2. Your app stays responsive
  3. The failing service gets breathing room
  4. Recovery is detected automatically

Circuit States

     ┌──────────────────────────────────────────────┐
     │                                              │
     ▼                                              │
┌─────────┐   failure threshold reached    ┌──────────┐
│ CLOSED  │ ──────────────────────────────►│   OPEN   │
│ (normal)│                                │ (failing)│
└─────────┘                                └──────────┘
     ▲                                          │
     │                                          │ after openDuration
     │                                          ▼
     │    success threshold reached     ┌────────────┐
     └──────────────────────────────────│ HALF_OPEN  │
                                        │ (testing)  │
                                        └────────────┘

                                              │ failure in HALF_OPEN

                                         back to OPEN
  • CLOSED: Normal operation. Requests pass through, failures are tracked.
  • OPEN: Circuit tripped. Requests immediately return 503 (Service Unavailable).
  • HALF_OPEN: Testing recovery. Limited requests allowed to check if service recovered.

Basic Usage

ts
import { FetchClientProvider } from "@foundatiofx/fetchclient";

const provider = new FetchClientProvider();
provider.setBaseUrl("https://api.example.com");

provider.useCircuitBreaker({
  failureThreshold: 5,    // Open after 5 failures
  openDurationMs: 30000,  // Stay open for 30 seconds
  successThreshold: 2,    // Close after 2 successes in HALF_OPEN
});

const client = provider.getFetchClient();

// Normal requests
const response = await client.getJSON("/users");

// If the API starts failing (5xx errors), after 5 failures:
// - Circuit opens
// - Subsequent requests get 503 immediately (no network call)
// - After 30 seconds, circuit enters HALF_OPEN
// - If test requests succeed, circuit closes

Per-Domain Circuit Breaker

Each domain gets its own circuit breaker, so one failing service doesn't affect others:

ts
provider.usePerDomainCircuitBreaker({
  failureThreshold: 5,
  openDurationMs: 30000,
});

// api1 failing doesn't affect api2
await client.getJSON("https://api1.example.com/data"); // Circuit for api1
await client.getJSON("https://api2.example.com/data"); // Circuit for api2

Configuration Options

ts
provider.useCircuitBreaker({
  // When to open the circuit
  failureThreshold: 5,      // Number of failures before opening (default: 5)
  failureWindowMs: 60000,   // Time window for counting failures (default: 60000)

  // Recovery
  openDurationMs: 30000,    // Time to stay OPEN before testing (default: 30000)
  successThreshold: 2,      // Successes needed to close circuit (default: 2)
  halfOpenMaxAttempts: 1,   // Max concurrent test requests (default: 1)

  // What counts as failure
  isFailure: (response) => response.status >= 500,
});

What Counts as a Failure?

By default, these are treated as failures:

  • HTTP 5xx responses (server errors)
  • HTTP 429 responses (rate limited)
  • Network errors (connection refused, timeout, etc.)

Customize failure detection:

ts
provider.useCircuitBreaker({
  isFailure: (response) => {
    // Only count 5xx as failures
    return response.status >= 500;
  },
});

// Or be more aggressive
provider.useCircuitBreaker({
  isFailure: (response) => {
    // Count any non-2xx as failure
    return response.status < 200 || response.status >= 300;
  },
});

Handling Open Circuit

Return 503 Response (Default)

ts
const response = await client.getJSON("/users");

if (response.status === 503) {
  // Circuit is open - show cached data or error message
  const retryAfter = response.response?.headers.get("Retry-After");
  console.log(`Service unavailable. Retry after ${retryAfter} seconds`);
}

Throw Error

ts
import { CircuitOpenError } from "@foundatiofx/fetchclient";

provider.useCircuitBreaker({
  throwOnOpen: true,
});

try {
  await client.getJSON("/users");
} catch (error) {
  if (error instanceof CircuitOpenError) {
    console.log(`Circuit open for: ${error.group}`);
    console.log(`Retry after: ${error.retryAfter}ms`);
  }
}

State Change Callbacks

Monitor circuit state changes:

ts
provider.useCircuitBreaker({
  onStateChange: (from, to) => {
    console.log(`Circuit: ${from} → ${to}`);
    // Log to monitoring system
  },
  onOpen: (group) => {
    // Alert: Service is down
    alert(`Service ${group} is experiencing issues`);
  },
  onClose: (group) => {
    // Service recovered
    console.log(`Service ${group} has recovered`);
  },
  onHalfOpen: (group) => {
    // Testing recovery
    console.log(`Testing if ${group} has recovered...`);
  },
});

Manual Circuit Control

Access the circuit breaker for manual control:

ts
const breaker = provider.circuitBreaker!;

// Force open (e.g., during planned maintenance)
breaker.trip("https://api.example.com/users");

// Force close (e.g., after manual verification)
breaker.reset("https://api.example.com/users");

// Reset all circuits
breaker.reset();

// Check state
const state = breaker.getState("https://api.example.com/users");
console.log(state); // "CLOSED" | "OPEN" | "HALF_OPEN"

// Get failure count
const failures = breaker.getFailureCount("https://api.example.com/users");

// Time since circuit opened
const timeSinceOpen = breaker.getTimeSinceOpen("https://api.example.com/users");

// Time until HALF_OPEN
const timeUntilHalfOpen = breaker.getTimeUntilHalfOpen("https://api.example.com/users");

Combined with Rate Limiting

Use both patterns together:

ts
// Rate limiter prevents overwhelming healthy APIs
provider.useRateLimit({
  maxRequests: 100,
  windowSeconds: 60
});

// Circuit breaker stops requests to failing APIs
provider.useCircuitBreaker({
  failureThreshold: 5
});

Order matters: Rate limiting happens first. If you're rate limited, the request never reaches the circuit breaker.

Removing Circuit Breaker

ts
provider.removeCircuitBreaker();

Practical Example: Resilient API Client

ts
import {
  FetchClientProvider,
  CircuitOpenError,
  RateLimitError
} from "@foundatiofx/fetchclient";

const provider = new FetchClientProvider();
provider.setBaseUrl("https://api.example.com");

// Rate limit to stay under API limits
provider.usePerDomainRateLimit({
  maxRequests: 100,
  windowSeconds: 60,
  updateFromHeaders: true,
});

// Circuit breaker for resilience
provider.usePerDomainCircuitBreaker({
  failureThreshold: 5,
  openDurationMs: 30000,
  onOpen: (group) => {
    // Could show a toast, log to monitoring, etc.
    console.warn(`Service degraded: ${group}`);
  },
});

const client = provider.getFetchClient();

async function fetchUser(id: string) {
  const response = await client.getJSON(`/users/${id}`);

  if (response.status === 503) {
    // Circuit is open - return cached data or placeholder
    return getCachedUser(id) ?? { id, name: "Unknown", offline: true };
  }

  if (!response.ok) {
    throw new Error(`Failed to fetch user: ${response.status}`);
  }

  return response.data;
}

Circuit Breaker vs Rate Limiting

AspectRate LimiterCircuit Breaker
PurposePrevent overloading APIPrevent cascading failures
TriggerRequest count exceeds limitFailure count exceeds threshold
WhenBefore requestAfter response
BlocksExcess requestsAll requests to failing service
RecoveryAutomatic after time windowState machine (OPEN → HALF_OPEN → CLOSED)

Use both together for maximum resilience:

  • Rate limiter keeps you within API limits
  • Circuit breaker handles when things go wrong

Released under the MIT License.