Files
skills/docs/foundation/flow/retry/src/circuit-breaker/circuit-breaker.md
2026-05-29 08:20:56 +01:00

3.7 KiB

circuit-breaker

(no submodules)

Purpose

Provides CircuitBreaker, a state-machine implementation of the circuit-breaker pattern for protecting async functions against cascading failures. The circuit starts closed (normal operation) and opens after a configurable number of consecutive failures, temporarily blocking calls to give the underlying resource time to recover. After a timeout, the circuit enters half-open state to probe whether the resource has recovered, and closes again on sufficient consecutive successes.

State-change events are emitted via an internal EventBus from @suaveplan/events, enabling observability integrations, metrics, and alerting without polling.

Features

  • Three states: closed, open, half-open with deterministic transitions
  • Configurable failureThreshold, successThreshold, and timeout (open → half-open delay)
  • Optional halfOpenProbes limit to bound probe calls in half-open
  • Clock injection for deterministic testing and wall-time independence
  • stateChange event emitted on every transition via EventBus
  • reset() for forced closure (e.g., operator action or integration tests)
  • AbortSignal passed through to wrapped function

Usage: Basic Example

import { CircuitBreaker } from "@suaveplan/retry";

const breaker = new CircuitBreaker({
  failureThreshold: 5,
  successThreshold: 2,
  timeout: 30_000, // ms before half-open
});

// Use breaker.call() instead of calling fn directly
const data = await breaker.call((signal) => fetchData(url, { signal }));

Usage: Advanced Example

import { CircuitBreaker, withRetry, createExponentialBackoff } from "@suaveplan/retry";

const breaker = new CircuitBreaker({
  failureThreshold: 3,
  successThreshold: 2,
  timeout: 10_000,
  clock: () => performance.now(),
});

// Subscribe to state changes for metrics
breaker.on("stateChange", ({ from, to }) => {
  metrics.increment("circuit_breaker.transition", { from, to });
});

// Combine with retry — circuit rejects fast while open
const delay = createExponentialBackoff({ initial: 200, max: 5_000 });

const result = await withRetry(
  (signal) => breaker.call(() => callDownstreamService(signal)),
  {
    maxAttempts: 6,
    delay,
    shouldRetry: (err) => !(err instanceof CircuitOpenError),
  },
);

API Reference

new CircuitBreaker(config)

Parameter Type Description
config.failureThreshold number Consecutive failures to open the circuit.
config.successThreshold number Consecutive successes in half-open to close.
config.timeout number Ms before open transitions to half-open.
config.halfOpenProbes number? Max probes in half-open (default: successThreshold).
config.clock Clock? Time source. Defaults to Date.now.

breaker.state

CircuitState — one of "closed", "open", "half-open".

breaker.on(event, handler)

Subscribe to "stateChange" events. Returns an unsubscribe function.

breaker.call(fn, signal?)

Execute fn through the circuit. Throws CircuitOpenError if open.

breaker.reset()

Force circuit to closed and reset all counters. Emits stateChange unless already closed.

Implementation Notes

The checkHalfOpen method is called lazily at the start of every call() — this avoids background timers and keeps the circuit breaker self-contained. The openedAt timestamp is set using the injected clock so deterministic tests can advance time by mutating the clock reference rather than using real timers. The EventBus is internal and not exposed as a public property to prevent external mutation. Listeners receive typed payloads without requiring any casting.