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-openwith deterministic transitions - Configurable
failureThreshold,successThreshold, andtimeout(open → half-open delay) - Optional
halfOpenProbeslimit to bound probe calls in half-open - Clock injection for deterministic testing and wall-time independence
stateChangeevent emitted on every transition viaEventBusreset()for forced closure (e.g., operator action or integration tests)AbortSignalpassed 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.