Skip to content

Circuit Breaker

@modularityjs/circuit-breaker is the circuit-breaker primitive: a lazy, timer-free closed → open → half-open state machine that stops your app from hammering a dependency that keeps failing. Zero deps, no DI, no Module — importable from anywhere without module wiring. The @WithCircuitBreaker method decorator ships separately in @modularityjs/circuit-breaker-plugins.

How it works

  • closed — calls pass through. Consecutive failures are counted; hitting failureThreshold trips the circuit.
  • open — calls are refused immediately with CircuitOpenException (no call to the dependency at all). After resetTimeoutMs the breaker becomes half-open.
  • half-open — up to halfOpenMaxProbes calls are admitted as probes. successThreshold consecutive probe successes close the circuit; any probe failure re-opens it.

State transitions are evaluated lazily from the clock — the breaker owns no timers, so it needs no lifecycle hooks and nothing to dispose. You can construct one anywhere, including inside a plain class.

CircuitBreaker

typescript
import { CircuitBreaker } from '@modularityjs/circuit-breaker';

const breaker = new CircuitBreaker('billing-api', {
  failureThreshold: 5, // consecutive failures that trip (default 5)
  resetTimeoutMs: 30_000, // open duration before probing (default 30s)
  successThreshold: 1, // probe successes to close (default 1)
  halfOpenMaxProbes: 1, // concurrent probes admitted (default 1)
});

const invoice = await breaker.execute(() => billingApi.fetchInvoice(id));

execute(fn) throws CircuitOpenException without invoking fn when no call is admitted; otherwise it records the outcome and passes the value through (or rethrows the original error).

The canExecute / recordSuccess / recordFailure triad

When "failure" isn't a throw — a non-ok HTTP response, a rejected batch — use the manual triad instead of execute:

typescript
if (!breaker.canExecute()) {
  return cachedFallback();
}
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
if (response.ok) {
  breaker.recordSuccess();
} else {
  breaker.recordFailure(new Error(`HTTP ${response.status}`));
}

In half-open, a true return from canExecute() reserves a probe slot — the caller must follow up with recordSuccess or recordFailure.

Filtering what counts as failure

isFailure decides which recorded errors count toward the threshold. A client error says nothing about downstream health:

typescript
new CircuitBreaker('billing-api', {
  isFailure: (error) => !(error instanceof ValidationException),
});

Errors filtered out still propagate to the caller — they just don't move the breaker.

Snapshots

typescript
breaker.getState(); // 'closed' | 'open' | 'half-open'
breaker.getSnapshot(); // { state, consecutiveFailures, openedAt, remainingOpenMs }
breaker.reset(); // force-close and clear all recorded state

CircuitBreakerGroup — per-key isolation

One flaky host must not open the circuit for the healthy ones. CircuitBreakerGroup is a lazy map of breakers sharing one policy, keyed by a caller-chosen key (typically the downstream host):

typescript
import { CircuitBreakerGroup } from '@modularityjs/circuit-breaker';

const breakers = new CircuitBreakerGroup({ failureThreshold: 3 });

await breakers
  .for(new URL(endpoint).host)
  .execute(() => deliver(endpoint, payload));

The group is bounded at policy.maxKeys (default 1024) with LRU eviction, so a high-cardinality key (per-URL) can't leak a breaker per key for the process lifetime. Eviction prefers closed breakers — a fresh breaker starts closed anyway, so no protection is lost.

CircuitOpenException

Extends ServiceUnavailableException from @modularityjs/exception, so HTTP boundaries map it to 503 with no extra wiring. It carries retryAfterMs — the time until the breaker admits a probe:

typescript
try {
  await breaker.execute(() => downstream.call());
} catch (error) {
  if (error instanceof CircuitOpenException) {
    logger.warn(`circuit open, retry in ${error.retryAfterMs}ms`);
  }
  throw error;
}

Retry loops should treat it as non-retryable (or wait at least retryAfterMs) — never compute a backoff delay past an open circuit. @Retryable's default retryOn already does this.

@WithCircuitBreaker (@modularityjs/circuit-breaker-plugins)

For DI-managed services, the decorator guards a method through the shared plugins interceptor seam:

typescript
import { Injectable } from '@modularityjs/di';
import { WithCircuitBreaker } from '@modularityjs/circuit-breaker-plugins';

@Injectable()
export class WebhookSender {
  @WithCircuitBreaker({
    policy: { failureThreshold: 3, resetTimeoutMs: 60_000 },
    key: (args) => new URL(args[0] as string).host, // per-host breakers
  })
  async deliver(endpoint: string, payload: unknown): Promise<void> {
    // ...
  }
}

Wire the module (it imports PluginsModule itself):

typescript
import { CircuitBreakerPluginsModule } from '@modularityjs/circuit-breaker-plugins';

const modules = [/* ... */ CircuitBreakerPluginsModule];

A decorated method whose module isn't wired fails boot loudly.

Options

typescript
interface WithCircuitBreakerOptions {
  /** Pass-through to the CircuitBreaker primitive; defaults are its own. */
  policy?: CircuitBreakerPolicy;
  /** Partitions breakers per call via CircuitBreakerGroup. Default: one breaker per decorated method. */
  key?: (args: unknown[]) => string;
  /** Breaker name carried by CircuitOpenException. Default `<Class>.<method>`. */
  name?: string;
  /** Interceptor chain position; lower = outermost. Default 300 (inside @Retryable). */
  order?: number;
}

Breaker state is shared across instances of the class — failure accounting is per decorated method (or per key).

Composing with @Retryable

Stacked with @Retryable (order 200 vs 300), the defaults put the breaker inside the retry loop: each attempt is admitted and recorded individually, and once the circuit opens the retry loop stops immediately instead of sleeping through backoff against a dead dependency.

typescript
@Retryable({ policy: { maxAttempts: 3 } })
@WithCircuitBreaker()
async fetchRates(): Promise<Rates> { /* ... */ }

Next Steps

  • Retry — the backoff-math primitive and @Retryable
  • Plugins — the method-interceptor seam and decorator ordering
  • Error Handling — the exception hierarchy and HTTP status mapping