npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

Readme

@apiratorjs/circuit-breaker

NPM version License: MIT

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 failure

With a Circuit Breaker:

Service A → Circuit Breaker → Service B (failing) → Fast fail after threshold → System remains stable

Key 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-breaker
yarn add @apiratorjs/circuit-breaker
pnpm add @apiratorjs/circuit-breaker

Migrating 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 a Promise. Previously it could throw CircuitOpenError synchronously; it now rejects with it. Code that already wrapped calls in await / .catch() needs no change — only a synchronous try { cb.execute() } catch {} does.
  • forceOpen(), forceClose() and forceHalfOpen() return a Promise<void>. They still update circuitBreaker.state synchronously, so existing code keeps working; await them if you need to know the store accepted the write.
  • @WithCircuitBreaker now 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. failureThreshold has always been documented as counting consecutive failures; it now behaves that way. Circuits that used to trip on failures spread far apart will need maxGapBetweenFailuresInMs — 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 open
  • durationOfBreakInMs (required): Duration in milliseconds to keep the circuit open before attempting recovery
  • successThreshold (required): Number of successful calls needed in half-open state to close the circuit
  • fallback (optional): Function to execute when circuit is open, receives same arguments as the operation
  • errorFilter (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 tally
  • name (required with a store, 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 name
  • store (optional): State backend, see Sharing state across processes. Defaults to a private InMemoryCircuitBreakerStore
  • storeErrorPolicy (optional, default LOCAL): What to do when the store itself is unreachable — see When the store is down
  • halfOpenMaxConcurrentAttempts (optional, default 1): 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-open
  • halfOpenSlotTtlInMs (optional, default durationOfBreakInMs): 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 open
  • durationTillNextAttemptInMs: 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 changes

Complete 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 of execute(). 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.
  • REJECT only 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 to to only if the circuit's current state is still from; return null when somebody got there first.
  • setState — move unconditionally. This backs the manual controls.
  • acquireHalfOpenSlot / releaseHalfOpenSlot — reserve and return one of the ctx.halfOpenMaxConcurrentAttempts permits for a trial call. A reservation must expire on its own after ctx.halfOpenSlotTtlInMs; releasing an unknown or already-expired token must be a no-op.
  • delete — drop everything stored under name, reservations included. The only method without a ctx, 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 UPDATE inside 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:

  • applySuccess returns the same object when nothing changed, so you can skip the write on the hot path where a healthy circuit just keeps succeeding.
  • applyFailure deliberately does not extend openedAt while 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:test by default. Pass runner: { describe, it, after } to drive it from vitest or jest instead. Use STORE_CONTRACT_CASES directly 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.now rather 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 (Redis PX), 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.json has "experimentalDecorators": true and "emitDecoratorMetadata": true enabled.

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.