@apiratorjs/circuit-breaker
v4.0.0
Published
A lightweight, dependency-free TypeScript circuit breaker for Node.js. Fails fast on unhealthy dependencies and recovers automatically, with fallbacks, error filtering, manual control, a method decorator, and pluggable stores that share one circuit across
Maintainers
Readme
@apiratorjs/circuit-breaker
A robust and lightweight TypeScript circuit breaker implementation for Node.js applications. Provides fault tolerance and stability by preventing cascading failures in distributed systems with configurable thresholds and automatic recovery.
Note: Requires Node.js version >=18.0.0
What is a Circuit Breaker and Why Use It?
A Circuit Breaker is a design pattern used in distributed systems to provide fault tolerance and prevent cascading failures. Just like an electrical circuit breaker that protects your home's electrical system from overload, a software circuit breaker protects your application from failing services.
How It Works
The circuit breaker monitors calls to external services and tracks failures. It has three states:
- 🟢 CLOSED: Normal operation - requests pass through and are monitored
- 🔴 OPEN: Failure threshold exceeded - requests fail fast without calling the service
- 🟡 HALF_OPEN: Testing phase - allows limited requests to check if service has recovered
Why You Need It
Without a Circuit Breaker:
Service A → Service B (failing) → Timeout after 30s → Retry → Another 30s timeout → Cascade failureWith a Circuit Breaker:
Service A → Circuit Breaker → Service B (failing) → Fast fail after threshold → System remains stableKey Benefits
- Fast Failure: Stop wasting time on calls to failing services
- System Stability: Prevent one failing service from bringing down your entire system
- Automatic Recovery: Automatically retry when services become healthy again
- Observability: Get insights into service health and failure patterns
- Performance: Reduce resource consumption and improve response times
Installation
npm install @apiratorjs/circuit-breakeryarn add @apiratorjs/circuit-breakerpnpm add @apiratorjs/circuit-breakerMigrating to v4
v4 adds pluggable state storage. Because a store may live out of process, the breaker's public surface is now uniformly asynchronous:
execute()always returns aPromise. Previously it could throwCircuitOpenErrorsynchronously; it now rejects with it. Code that already wrapped calls inawait/.catch()needs no change — only a synchronoustry { cb.execute() } catch {}does.forceOpen(),forceClose()andforceHalfOpen()return aPromise<void>. They still updatecircuitBreaker.statesynchronously, so existing code keeps working;awaitthem if you need to know the store accepted the write.@WithCircuitBreakernow builds one circuit breaker per instance instead of one per call, so failures actually accumulate. Previously every invocation got a fresh breaker and the circuit could never open.- A successful call now clears accumulated failures while the circuit is closed.
failureThresholdhas always been documented as counting consecutive failures; it now behaves that way. Circuits that used to trip on failures spread far apart will needmaxGapBetweenFailuresInMs— see Counting failures.
Quick Start
import { CircuitBreaker, CircuitOpenError } from '@apiratorjs/circuit-breaker';
// Define your service call function
async function callExternalService(data: any) {
// Your external service call here
const response = await fetch('https://api.example.com/data', {
method: 'POST',
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`Service responded with ${response.status}`);
}
return response.json();
}
// Create a circuit breaker with your operation and settings
const circuitBreaker = new CircuitBreaker(callExternalService, {
failureThreshold: 5, // Open circuit after 5 failures
durationOfBreakInMs: 60000, // Keep circuit open for 60 seconds
successThreshold: 2 // Close circuit after 2 successful calls in half-open state
});
// Use it in your application
try {
const result = await circuitBreaker.execute({ id: 123 });
console.log('Success:', result);
} catch (error) {
if (error instanceof CircuitOpenError) {
console.error('Circuit is open - service temporarily unavailable');
} else {
console.error('Service call failed:', error.message);
}
}Interface and options
ICircuitBreakerOptions
Configuration options for creating a circuit breaker instance:
interface ICircuitBreakerBaseOptions {
failureThreshold: number; // Number of failures before opening the circuit
durationOfBreakInMs: number; // How long to keep circuit open (milliseconds)
successThreshold: number; // Successful calls needed to close circuit from half-open
fallback?: (...args: any[]) => any; // Fallback function when circuit is open
errorFilter?: (error: Error) => boolean; // Custom error filtering logic
maxGapBetweenFailuresInMs?: number; // Max quiet stretch before the tally resets
storeErrorPolicy?: ECircuitBreakerStoreErrorPolicy; // Behaviour when the store fails
halfOpenMaxConcurrentAttempts?: number; // Concurrent trial calls in half-open
halfOpenSlotTtlInMs?: number; // How long a half-open slot stays reserved
}
// State in a store shared across processes: the name is the identifier every
// process has to agree on, so it is mandatory here.
interface ISharedCircuitBreakerOptions extends ICircuitBreakerBaseOptions {
store: ICircuitBreakerStore;
name: string;
}
// The classic single-process circuit, backed by a private in-memory store.
interface ILocalCircuitBreakerOptions extends ICircuitBreakerBaseOptions {
store?: undefined;
name?: string; // Only a label for logs; generated when omitted
}
type ICircuitBreakerOptions =
| ISharedCircuitBreakerOptions
| ILocalCircuitBreakerOptions;Passing a store without a name is a compile error, not a runtime surprise: a
generated name is local to the process, so each worker would silently operate on
its own circuit and the shared store would share nothing.
Options Details
failureThreshold(required): Number of consecutive failures that will trigger the circuit to opendurationOfBreakInMs(required): Duration in milliseconds to keep the circuit open before attempting recoverysuccessThreshold(required): Number of successful calls needed in half-open state to close the circuitfallback(optional): Function to execute when circuit is open, receives same arguments as the operationerrorFilter(optional): Function to determine if an error should count as a failure (return true to count, false to ignore)maxGapBetweenFailuresInMs(optional): How long the failure tally survives without a new failure. Two failures further apart than this are treated as unrelated and the later one starts counting from 1. Measured from the previous failure, so a steady run keeps adding up however long it lasts — see Counting failures. Unset by default, in which case only a success clears the tallyname(required with astore, optional without): Identifies this circuit inside the store — the name every process has to agree on. With no store it is just a label for logs and state-change callbacks, and defaults to a unique per-instance namestore(optional): State backend, see Sharing state across processes. Defaults to a privateInMemoryCircuitBreakerStorestoreErrorPolicy(optional, defaultLOCAL): What to do when the store itself is unreachable — see When the store is downhalfOpenMaxConcurrentAttempts(optional, default1): How many trial calls may run at once in half-open. Keeps a recovering dependency from being hit by every caller at the same moment — see Trial calls in half-openhalfOpenSlotTtlInMs(optional, defaultdurationOfBreakInMs): How long a half-open slot stays reserved before the store may hand it to somebody else, so a caller that dies mid-call cannot block recovery
Circuit Breaker States
enum ECircuitBreakerState {
CLOSED = "closed", // Normal operation, calls pass through
OPEN = "open", // Circuit is open, calls are rejected immediately
HALF_OPEN = "half_open" // Testing recovery, limited calls allowed
}Error Handling
The circuit breaker throws specific error types that you can catch and handle appropriately:
Error Types
CircuitOpenError
Thrown when the circuit breaker is in the OPEN state and prevents execution of the wrapped function.
Properties:
cause?: TErrorLike- The original error that caused the circuit to opendurationTillNextAttemptInMs: number- Time in milliseconds until the circuit breaker will attempt recovery
import { CircuitBreaker, CircuitOpenError } from '@apiratorjs/circuit-breaker';
const circuitBreaker = new CircuitBreaker(riskyOperation, {
failureThreshold: 3,
durationOfBreakInMs: 30000,
successThreshold: 2
});
try {
await circuitBreaker.execute();
} catch (error) {
if (error instanceof CircuitOpenError) {
console.log('Circuit is open, service is temporarily unavailable');
console.log('Original cause:', error.cause?.message);
console.log(`Next attempt in: ${error.durationTillNextAttemptInMs}ms`);
// You can use this to implement retry logic or user feedback
const nextAttemptTime = new Date(Date.now() + error.durationTillNextAttemptInMs);
console.log(`Service will be available again at: ${nextAttemptTime.toISOString()}`);
}
}CircuitArgumentError
Thrown when invalid configuration options are provided to the circuit breaker constructor.
try {
// This will throw CircuitArgumentError
const circuitBreaker = new CircuitBreaker(myFunction, {
failureThreshold: 0, // Invalid: must be > 0
durationOfBreakInMs: 30000,
successThreshold: 2
});
} catch (error) {
if (error instanceof CircuitArgumentError) {
console.log('Invalid configuration:', error.message);
}
}CircuitBreakerError
Base class for all circuit breaker errors. Contains additional error information:
import { CircuitBreakerError } from '@apiratorjs/circuit-breaker';
try {
await circuitBreaker.execute();
} catch (error) {
if (error instanceof CircuitBreakerError) {
console.log('Circuit breaker error:', error.toJSON());
// Output includes: name, message, cause (if available)
}
}State Change Monitoring
You can subscribe to state changes to monitor your circuit breaker's behavior and implement custom logging, metrics, or alerting.
onStateChange Method
The onStateChange method allows you to register a callback that will be called whenever the circuit breaker changes state. The callback receives a state transition object with both the previous and new states, plus an optional error when transitioning to OPEN:
import { CircuitBreaker, ECircuitBreakerState } from '@apiratorjs/circuit-breaker';
const circuitBreaker = new CircuitBreaker(riskyOperation, {
failureThreshold: 3,
durationOfBreakInMs: 30000,
successThreshold: 2
});
// Subscribe to state changes
circuitBreaker.onStateChange((stateTransition, error) => {
console.log(`Circuit breaker: ${stateTransition.previousState} → ${stateTransition.newState}`);
// Handle state transitions
if (stateTransition.newState === ECircuitBreakerState.OPEN) {
console.log('⚠️ Circuit opened due to failures');
if (error) {
console.log('Last error:', error.message);
}
// Send alert, update metrics, etc.
} else if (stateTransition.newState === ECircuitBreakerState.HALF_OPEN) {
console.log('🔄 Circuit is testing recovery');
// Log recovery attempt
} else if (stateTransition.newState === ECircuitBreakerState.CLOSED) {
console.log('✅ Circuit closed - service is healthy');
// Log successful recovery
}
});Callback Signature:
type TCircuitBreakerStateChangeCallback = (
stateTransition: ICircuitBreakerStateTransition,
error?: Error // Present only when transitioning to OPEN state
) => void;
interface ICircuitBreakerStateTransition {
previousState: ECircuitBreakerState; // The state before transition
newState: ECircuitBreakerState; // The new current state
}Advanced State Monitoring Example
class CircuitBreakerMonitor {
private metrics = {
stateChanges: 0,
totalFailures: 0,
recoveryAttempts: 0,
transitions: [] as Array<{
from: ECircuitBreakerState;
to: ECircuitBreakerState;
timestamp: Date;
error?: string;
}>
};
constructor(private circuitBreaker: CircuitBreaker) {
this.setupMonitoring();
}
private setupMonitoring() {
this.circuitBreaker.onStateChange((stateTransition, error) => {
this.metrics.stateChanges++;
// Track all transitions
this.metrics.transitions.push({
from: stateTransition.previousState,
to: stateTransition.newState,
timestamp: new Date(),
error: error?.message
});
// Handle specific transitions
if (stateTransition.newState === ECircuitBreakerState.OPEN) {
this.metrics.totalFailures++;
this.onCircuitOpened(stateTransition.previousState, error);
} else if (stateTransition.newState === ECircuitBreakerState.HALF_OPEN) {
this.metrics.recoveryAttempts++;
this.onRecoveryAttempt(stateTransition.previousState);
} else if (stateTransition.newState === ECircuitBreakerState.CLOSED) {
this.onCircuitClosed(stateTransition.previousState);
}
});
}
private onCircuitOpened(from: ECircuitBreakerState, error?: Error) {
console.log(`🚨 ALERT: Circuit breaker opened (${from} → OPEN)`);
console.log('Error details:', error?.message);
// Send to monitoring system
// this.sendAlert('circuit_breaker_opened', {
// from,
// error: error?.message
// });
}
private onRecoveryAttempt(from: ECircuitBreakerState) {
console.log(`🔄 Circuit breaker attempting recovery (${from} → HALF_OPEN)`);
// Log recovery attempt
// this.logMetric('circuit_breaker_recovery_attempt');
}
private onCircuitClosed(from: ECircuitBreakerState) {
console.log(`✅ Circuit breaker recovered (${from} → CLOSED)`);
// Log successful recovery
// this.logMetric('circuit_breaker_recovered');
}
public getMetrics() {
return {
...this.metrics,
currentState: this.circuitBreaker.state
};
}
}
// Usage
const monitor = new CircuitBreakerMonitor(circuitBreaker);
// Check metrics after some operations
console.log(monitor.getMetrics());
// Output includes:
// - stateChanges: number of state transitions
// - totalFailures: number of times circuit opened
// - recoveryAttempts: number of recovery attempts
// - transitions: detailed history of all state changesComplete Example with Error Handling and State Monitoring
import {
CircuitBreaker,
ECircuitBreakerState,
CircuitOpenError,
CircuitBreakerError
} from '@apiratorjs/circuit-breaker';
// Define your risky operation
async function riskyOperation(data: any) {
// Simulate a service that fails sometimes
if (Math.random() < 0.7) {
throw new Error('Service temporarily unavailable');
}
return { success: true, data };
}
const circuitBreaker = new CircuitBreaker(riskyOperation, {
failureThreshold: 3,
durationOfBreakInMs: 30000,
successThreshold: 2
});
// Set up comprehensive state change monitoring
circuitBreaker.onStateChange((stateTransition, error) => {
console.log(`🔄 Circuit breaker: ${stateTransition.previousState} → ${stateTransition.newState}`);
if (error) {
console.log(`Triggered by error: ${error.message}`);
}
});
// Example usage with proper error handling
async function makeServiceCall(data: any) {
try {
const result = await circuitBreaker.execute(data);
console.log('✅ Service call successful:', result);
return result;
} catch (error) {
if (error instanceof CircuitOpenError) {
console.log('⚠️ Circuit is open - service temporarily unavailable');
console.log('Original cause:', error.cause?.message);
console.log(`⏱️ Next attempt in: ${error.durationTillNextAttemptInMs}ms`);
// Handle circuit open scenario (e.g., return cached data, show user message)
// You can use durationTillNextAttemptInMs for user feedback or retry scheduling
const retryTime = new Date(Date.now() + error.durationTillNextAttemptInMs);
console.log(`Service will retry at: ${retryTime.toLocaleTimeString()}`);
} else if (error instanceof CircuitBreakerError) {
console.log('🔧 Circuit breaker error:', error.toJSON());
} else {
console.log('❌ Service call failed:', error.message);
// Handle other service errors
}
throw error;
}
}
// Check current state
console.log('Current state:', circuitBreaker.state);
// Example of multiple calls to demonstrate state changes
async function demonstrateCircuitBreaker() {
for (let i = 0; i < 10; i++) {
try {
await makeServiceCall({ attempt: i + 1 });
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
} catch (error) {
// Continue with next attempt
}
}
}Advanced Features
Fallback Function
Provide a fallback when the circuit is open:
const circuitBreaker = new CircuitBreaker(fetchData, {
failureThreshold: 3,
durationOfBreakInMs: 30000,
successThreshold: 2,
fallback: (userId) => {
// Return cached data instead of throwing error
return cache.get(userId) || { id: userId, name: 'Unknown' };
}
});
// When circuit is open, fallback is called automatically
const data = await circuitBreaker.execute('user-123');Error Filtering
Filter which errors should count as failures:
const circuitBreaker = new CircuitBreaker(apiCall, {
failureThreshold: 5,
durationOfBreakInMs: 60000,
successThreshold: 2,
errorFilter: (error) => {
// Ignore validation errors
if (error.name === 'ValidationError') return false;
// Only count 5xx server errors as failures
if ('status' in error) {
return error.status >= 500 && error.status < 600;
}
return true; // Count other errors
}
});Manual Circuit Control
Manually control circuit state for maintenance or testing:
const circuitBreaker = new CircuitBreaker(operation, options);
// Manual control. The local view updates immediately; await the promise if you
// need to know that the store accepted the write.
await circuitBreaker.forceOpen(); // Open circuit (e.g., for maintenance)
await circuitBreaker.forceClose(); // Close and reset
await circuitBreaker.forceHalfOpen(); // Force to half-open state
// Check state
console.log(circuitBreaker.state); // 'open', 'closed', or 'half_open'Sharing state across processes
By default a circuit breaker keeps its state in memory, private to the instance that owns it. That is the right thing for a single process, but it falls apart when the same dependency is called from several workers — each one has to rediscover the outage on its own, and every worker keeps hammering a service the others already gave up on. Background jobs that retry are the classic case.
Point the breaker at a store and the state becomes shared instead:
import { CircuitBreaker } from '@apiratorjs/circuit-breaker';
import { RedisCircuitBreakerStore } from 'some-redis-adapter';
const store = new RedisCircuitBreakerStore({ url: process.env.REDIS_URL });
const circuitBreaker = new CircuitBreaker(callPaymentProvider, {
failureThreshold: 5,
durationOfBreakInMs: 30_000,
successThreshold: 2,
name: 'payment-provider', // the name every worker agrees on
store,
});Every worker that builds a breaker with the same name and a store backed by
the same Redis now sees one circuit: when one worker opens it, the rest fail fast
immediately, and the state survives a restart.
This works nicely with CircuitOpenError.durationTillNextAttemptInMs — it tells
you when the shared circuit is due to retry, which is exactly the delay to
reschedule a job with:
try {
await circuitBreaker.execute(payload);
} catch (error) {
if (error instanceof CircuitOpenError) {
await job.moveToDelayed(Date.now() + error.durationTillNextAttemptInMs);
return;
}
throw error;
}Observing changes made elsewhere
circuitBreaker.state is the last state this instance observed. When another
process moves the circuit, call refresh() to re-read the store — it also fires
onStateChange if the state moved while you were not looking:
const snapshot = await circuitBreaker.refresh();
console.log(snapshot.state, snapshot.failureCount, snapshot.openedAt);Trial calls in half-open
Half-open means "let one call through and see whether the dependency recovered". With several workers that needs coordinating: the moment the break window elapses, every one of them would otherwise decide it is time to try, and the recovering dependency takes the full burst.
So before making the call the breaker reserves a half-open slot from the store, and gives it back when the call settles:
const slot = await store.acquireHalfOpenSlot(name, ctx);
if (!slot.acquired) { /* fall back, or throw CircuitOpenError */ }
try { /* the trial call */ } finally {
await store.releaseHalfOpenSlot(name, slot.token, ctx);
}There are halfOpenMaxConcurrentAttempts slots (default 1), so by default
exactly one trial call runs at a time across the whole fleet. Callers that do not
get one fall back or receive a CircuitOpenError, just as if the circuit were
still open.
halfOpenSlotTtlInMs is the safety net for one specific case: the worker holding
a slot dies — kill -9, OOM, network gone — and never reaches
releaseHalfOpenSlot. Without an expiry that slot stays taken forever and the
circuit can never close again. It is not an operation timeout and does not come
into play on a normal call, where the slot is released as soon as the call
settles.
Size it against how long the operation takes. Too short and the reservation
expires while the trial call is still in flight, letting a second worker start
its own; too long and recovery is delayed by that much after a worker dies. The
default is durationOfBreakInMs — if your call has a 5s timeout and the break is
60s, something like halfOpenSlotTtlInMs: 10_000 frees a dead worker's slot in
ten seconds instead of a minute.
When the store is down
A circuit breaker exists to keep a failing dependency from taking your service
with it — so it must not turn its own store into a new way for the service to
fail. storeErrorPolicy decides what happens when a store call throws:
import { ECircuitBreakerStoreErrorPolicy } from '@apiratorjs/circuit-breaker';LOCAL(default): degrade to a private in-memory store seeded with the last snapshot this instance saw, and keep breaking per process until the store recovers. Protection shrinks to one process instead of disappearing — and a circuit that was already open stays open rather than releasing traffic onto a dependency you know is failing.ALLOW: run the operation as if the circuit were closed. Prefers keeping calls flowing over protecting the dependency.REJECT: propagate the store error out ofexecute(). Prefers failing loudly.
Degradation is otherwise invisible, so there is a hook for it:
circuitBreaker.onStoreError((error, operation) => {
logger.warn({ err: error, operation }, 'circuit breaker store unavailable');
});The policy is itself a store — ResilientCircuitBreakerStore wraps the one you
supplied and implements ICircuitBreakerStore on top of it. The circuit breaker
above it just calls the interface and never has to know a fallback exists. You
can use it directly to give any store the same treatment:
const store = new ResilientCircuitBreakerStore(
redisStore,
ECircuitBreakerStoreErrorPolicy.LOCAL
);Two details worth knowing:
- Every call gives the real store a fresh chance; there is no latching onto the fallback. The moment the store answers again it is authoritative, and the in-memory fallback carries the last state seen — including work it did itself during the outage — so protection never restarts from scratch mid-outage.
REJECTonly applies to calls made before the operation runs (get,compareAndSetState,acquireHalfOpenSlot,setState). Bookkeeping that happens afterwards —recordSuccess,recordFailure,releaseHalfOpenSlot— never propagates, because discarding a completed result, or replacing the operation's own error with a Redis timeout, helps nobody.
Counting failures
failureThreshold counts consecutive failures: a successful call clears the
tally. That matters most for a long-lived circuit, especially a shared one — a
counter that only ever grows will eventually trip a perfectly healthy dependency
on a handful of unrelated failures spread over days.
A success resets the count, but a circuit that sees traffic rarely may not get
one in time. maxGapBetweenFailuresInMs gives the tally a second way to clear:
a quiet stretch.
const circuitBreaker = new CircuitBreaker(callProvider, {
failureThreshold: 5,
maxGapBetweenFailuresInMs: 60_000, // failures more than a minute apart are unrelated
durationOfBreakInMs: 30_000,
successThreshold: 2,
});Read it as "failures have to keep coming to count together" — not as "5 failures in the last minute". The gap is measured from the previous failure and reset by each new one, so a run never times out as long as it keeps going:
| Failure at | Gap since previous | Tally | With maxGapBetweenFailuresInMs: 60_000 |
| --- | --- | --- | --- |
| 00:00 | — | 1 | run starts |
| 00:50 | 50s | 2 | within the gap, run continues |
| 01:40 | 50s | 3 | still going — total span is already 100s |
| 03:00 | 80s | 1 | too long a pause; this is a new run |
That is deliberately not a sliding window. A sliding window would ask "how many
failures in the last 60 seconds" and answer 1 at 01:40. Here the question is
whether each failure arrived soon enough after the one before it — a dependency
failing steadily every 50 seconds is failing steadily, and the circuit should
notice.
Pick the value as "how far apart can two failures be and still plausibly be the
same incident" — usually seconds to tens of seconds. Too large brings back the
accumulation problem; too small and a rare-but-steady error stream never opens
the circuit at all. Must be greater than zero: 0 reads as "unset" and is
rejected at construction.
Without it, failures are remembered until a success arrives.
Writing a store
This package ships only InMemoryCircuitBreakerStore. Backends such as Redis or
Postgres live in separate packages that implement ICircuitBreakerStore.
The two values an adapter handles
The snapshot is the complete state of one circuit — everything needed to reconstruct it in another process:
interface ICircuitBreakerStateSnapshot {
state: ECircuitBreakerState; // closed | open | half_open
failureCount: number; // consecutive counted failures while closed
successCount: number; // successful trial calls while half-open
lastFailureAt: number | null; // epoch ms
openedAt: number | null; // epoch ms the circuit went open
}It is deliberately JSON-serializable. The last error is not part of it — each
breaker keeps that locally, so a store never has to serialize Error objects.
The context carries the thresholds and the caller's clock, and is passed on every single call:
interface ICircuitBreakerStoreContext {
failureThreshold: number;
successThreshold: number;
durationOfBreakInMs: number;
halfOpenMaxConcurrentAttempts: number;
halfOpenSlotTtlInMs: number;
maxGapBetweenFailuresInMs?: number;
now: number;
}Because the configuration arrives with each call, a store stays stateless and one instance can back any number of circuits with different thresholds.
Whose clock is it
ctx.now is the calling process's Date.now(), and every timing decision is made
against it. Never read the clock yourself — a store that substitutes its own
puts two different times on either side of the same comparison, which is worse
than a skewed one.
Worth knowing where this leaves a fleet: openedAt is written by whichever worker
opened the circuit, and "has the break window elapsed" is evaluated by each
worker locally, before it ever reaches the store. So clock skew between workers
shifts the break window by exactly that skew, and no adapter can correct for it —
the decision has already been made by the time your code runs. With ordinary NTP
this is tens of milliseconds against windows measured in seconds. If your fleet's
clocks are not disciplined, that assumption is the first thing to question.
The one place a store may use its backend's clock is the half-open slot: expiring
a reservation with Redis PX or a database column is expected, and the contract
suite accommodates it.
The interface
interface ICircuitBreakerStore {
get(name, ctx): Promise<ICircuitBreakerStateSnapshot>;
recordSuccess(name, ctx): Promise<ICircuitBreakerStateSnapshot>;
recordFailure(name, ctx): Promise<ICircuitBreakerStateSnapshot>;
compareAndSetState(name, from, to, ctx): Promise<ICircuitBreakerStateSnapshot | null>;
setState(name, next, ctx): Promise<ICircuitBreakerStateSnapshot>;
acquireHalfOpenSlot(name, ctx): Promise<IHalfOpenSlot>;
releaseHalfOpenSlot(name, token, ctx): Promise<void>;
delete(name): Promise<void>;
}What each one owes the caller:
get— the current snapshot; an initial closed one when the name is unknown.recordSuccess/recordFailure— apply the transition and return the resulting snapshot. Mutating methods return the new state so the breaker learns the outcome without a second round-trip.compareAndSetState— move totoonly if the circuit's current state is stillfrom; returnnullwhen somebody got there first.setState— move unconditionally. This backs the manual controls.acquireHalfOpenSlot/releaseHalfOpenSlot— reserve and return one of thectx.halfOpenMaxConcurrentAttemptspermits for a trial call. A reservation must expire on its own afterctx.halfOpenSlotTtlInMs; releasing an unknown or already-expired token must be a no-op.delete— drop everything stored undername, reservations included. The only method without actx, deliberately: dropping a key needs neither the thresholds nor the clock, and a parameter every implementation ignores is worse than an asymmetric signature.
The one hard requirement: atomicity
A store owns the transitions, not just the bytes. recordSuccess,
recordFailure and compareAndSetState must apply the state machine
atomically with respect to other callers. A read-modify-write that is not
atomic loses increments, and the effective threshold drifts upward under load —
a circuit configured to open after 5 failures quietly starts needing 8, exactly
when you need it most.
- SQL: take a row lock —
SELECT ... FOR UPDATEinside the transaction that writes the new snapshot. - Redis: put the whole read-modify-write in one Lua script.
compareAndSetState returning null on a lost race is what stops an entire
fleet from entering half-open the moment the break window elapses.
Reuse the state machine
The transition rules are exported as CircuitBreakerStateMachine, a stateless
class of static rules. Do not reimplement them — an adapter that disagrees with
the core about when a circuit opens is a very hard bug to find:
import { CircuitBreakerStateMachine } from '@apiratorjs/circuit-breaker';
// Postgres adapter, inside SELECT ... FOR UPDATE
async recordFailure(name, ctx) {
const current =
(await this.selectForUpdate(name)) ??
CircuitBreakerStateMachine.createInitialSnapshot();
const next = CircuitBreakerStateMachine.applyFailure(current, ctx);
await this.upsert(name, next);
return next;
}The full set: createInitialSnapshot, applySuccess, applyFailure,
applyForcedState, continuesFailureChain, shouldAttemptReset and
durationTillNextAttemptInMs.
Two of them are worth knowing about even when you only store bytes:
applySuccessreturns the same object when nothing changed, so you can skip the write on the hot path where a healthy circuit just keeps succeeding.applyFailuredeliberately does not extendopenedAtwhile the circuit is already open, so a straggling failure cannot stretch the break window.
A backend that cannot call into JavaScript has to express the same rules natively. Keep that version side by side with the exported functions, and hold it to the contract suite below.
The contract test suite
Do not take the prose above on trust — run the contract. The behavior every store owes its caller is published as a ready-made suite:
import { runStoreContractTests } from '@apiratorjs/circuit-breaker/testing';
runStoreContractTests({
suiteName: 'RedisCircuitBreakerStore',
createStore: () => new RedisCircuitBreakerStore({ url: process.env.REDIS_URL! }),
teardown: (store) => store.disconnect(),
});That is the whole integration. It covers the state machine as seen through the
store, compareAndSetState races, the half-open slot lifecycle including
expiry and ownership, and two concurrency cases that a non-atomic
read-modify-write cannot pass. InMemoryCircuitBreakerStore is held to the same
suite in this package's own tests.
- Runner:
node:testby default. Passrunner: { describe, it, after }to drive it from vitest or jest instead. UseSTORE_CONTRACT_CASESdirectly if you need to filter or wrap individual cases. - Shared backends are safe: every circuit name is unique per case and deleted afterwards, so the suite can point at a real Redis or database.
- Time is virtual: cases move
ctx.nowrather than sleeping, which is why "never read the clock yourself" matters. The one exception is slot expiry — a store may legitimately delegate that to its backend (RedisPX), so that case waits for real time too.
Reference implementation
InMemoryCircuitBreakerStore (~150 lines) performs exactly this sequence with no
locking, because a single process needs none. Read it as the specification of
what your adapter must reproduce under concurrency.
You do not need to handle your own backend being unreachable: wrap the store in
ResilientCircuitBreakerStore, or just pass it to a CircuitBreaker, which does
that for you — see When the store is down.
Using decorator (for typescript projects)
Method Decorator (@WithCircuitBreaker)
Protect class methods using TypeScript decorators:
import { WithCircuitBreaker } from '@apiratorjs/circuit-breaker';
class UserService {
@WithCircuitBreaker({
failureThreshold: 5,
durationOfBreakInMs: 60000,
successThreshold: 2
})
async fetchUser(id: string) {
const response = await fetch(`https://api.example.com/users/${id}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
@WithCircuitBreaker({
failureThreshold: 3,
durationOfBreakInMs: 45000,
successThreshold: 1
})
async updateUser(id: string, data: any) {
const response = await fetch(`https://api.example.com/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
}
// Usage
const userService = new UserService();
try {
const user = await userService.fetchUser('123');
await userService.updateUser('123', { name: 'John Doe' });
} catch (error) {
console.error('Service call failed:', error.message);
}Note: To use decorators, ensure your
tsconfig.jsonhas"experimentalDecorators": trueand"emitDecoratorMetadata": trueenabled.
Each decorated method gets one circuit per instance it is called on, created on
the first call. Two instances of UserService therefore fail independently —
which is usually what you want, since they often point at different hosts. To
make them share one circuit, give them a store and the same name.
Options from the instance (dependency injection)
A decorator runs while the class is being defined, long before a DI container has built anything — so an injected store cannot be named in the options object. Pass a function instead. It receives the instance and runs on the first call, by which time the container is done:
@Injectable()
export class PaymentsService {
constructor(
@Inject(CIRCUIT_BREAKER_STORE) public readonly store: ICircuitBreakerStore,
) {}
@WithCircuitBreaker((self: PaymentsService) => ({
store: self.store,
name: 'payments.charge',
failureThreshold: 5,
durationOfBreakInMs: 30_000,
successThreshold: 2,
}))
async charge(id: string) {
// ...
}
}The function runs once per instance, alongside the breaker it configures — not on every call — so it is the wrong place for anything that varies per invocation.
Keep name a stable constant. It is the identifier every process has to agree
on, so deriving it from the instance gives each worker its own circuit and the
shared store stops sharing anything.
Contributing
Contributions, issues, and feature requests are welcome! Feel free to check issues page.
License
This project is MIT licensed.
