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
failureThresholdtrips the circuit. - open — calls are refused immediately with
CircuitOpenException(no call to the dependency at all). AfterresetTimeoutMsthe breaker becomes half-open. - half-open — up to
halfOpenMaxProbescalls are admitted as probes.successThresholdconsecutive 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
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:
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:
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
breaker.getState(); // 'closed' | 'open' | 'half-open'
breaker.getSnapshot(); // { state, consecutiveFailures, openedAt, remainingOpenMs }
breaker.reset(); // force-close and clear all recorded stateCircuitBreakerGroup — 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):
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:
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:
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):
import { CircuitBreakerPluginsModule } from '@modularityjs/circuit-breaker-plugins';
const modules = [/* ... */ CircuitBreakerPluginsModule];A decorated method whose module isn't wired fails boot loudly.
Options
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.
@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