@xyph3r/circuit-breaker
v0.1.0
Published
Wrap any async function with circuit breaker semantics — closed, open, half-open.
Maintainers
Readme
@xyph3r/circuit-breaker
Wrap any async function with circuit breaker semantics.
When a dependency starts failing, the circuit breaker stops calling it. Your service degrades gracefully instead of piling up timeouts. When the dependency recovers, traffic resumes automatically.
Three states:
- Closed — normal operation. Calls go through. Failures are tracked.
- Open — the dependency is considered down. Calls are rejected immediately or routed to a fallback. No traffic reaches the dependency.
- Half-open — a probe call is allowed through to test recovery. Success closes the circuit. Failure reopens it.
Install
npm install @xyph3r/circuit-breakeror
bun add @xyph3r/circuit-breakerWhen to use it
Use a circuit breaker when:
- you call an external API, database, or service that can go down
- slow responses from a dependency can exhaust your connection pool or threads
- you want automatic recovery detection without manual intervention
- you need a fallback path when a dependency is unavailable
Do not use it when:
- the call is purely in-memory with no external dependency
- failures are expected and handled by business logic (e.g. validation errors)
- you need per-request rate limiting (use
@xyph3r/rate-limiterinstead)
Quick start
import { CircuitBreakerBuilder } from "@xyph3r/circuit-breaker";
const breaker = new CircuitBreakerBuilder()
.forConsecutiveFailures({ threshold: 5 })
.withResetTimeout(30_000)
.build();
const result = await breaker.fire(() => fetch("https://api.example.com/data"));Failure detection strategies
Consecutive failures
Open the circuit after N consecutive failures. A single success resets the count.
const breaker = new CircuitBreakerBuilder()
.forConsecutiveFailures({ threshold: 5 })
.withResetTimeout(30_000)
.build();Use this when:
- a dependency is either up or down
- intermittent errors should not trip the breaker
- you want the simplest possible configuration
Failure rate
Open the circuit when the failure rate exceeds a threshold within a sliding time window. Requires a minimum number of samples before the rate is evaluated.
const breaker = new CircuitBreakerBuilder()
.forFailureRate({
threshold: 0.5,
windowMs: 60_000,
minSamples: 10,
})
.withResetTimeout(30_000)
.build();Use this when:
- a low background error rate is acceptable
- you want to react to sustained degradation, not a single burst
- the dependency handles mixed traffic with different reliability profiles
Fallback
Provide a fallback function when you want graceful degradation instead of errors.
const result = await breaker.fire(
() => fetch("https://api.example.com/data"),
() => ({ cached: true, data: getCachedData() }),
);The fallback runs when the circuit is open or when all half-open probe slots are occupied.
Wrapping a function
Use wrap() to create a permanently protected version of a function.
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`https://api.example.com/users/${id}`);
return response.json();
}
const safeFetchUser = breaker.wrap(fetchUser);
const user = await safeFetchUser("42");With a fallback:
const safeFetchUser = breaker.wrap(
fetchUser,
async (id) => ({ id, name: "Unknown", cached: true }),
);Filtering failures
By default, every thrown error counts as a failure. Use isFailure() to control which errors trip the breaker.
const breaker = new CircuitBreakerBuilder()
.forConsecutiveFailures({ threshold: 5 })
.withResetTimeout(30_000)
.isFailure((error) => {
if (error instanceof HttpError && error.status === 404) return false;
if (error instanceof HttpError && error.status === 400) return false;
return true;
})
.build();Errors that isFailure returns false for are still thrown to the caller — they just don't affect the circuit state.
Events
Subscribe to lifecycle events for logging, metrics, or alerting.
breaker.on("stateChange", (info) => {
console.log(`Circuit ${info.previousState} → ${info.state}`);
});
breaker.on("success", (info) => {
metrics.increment("circuit.success");
});
breaker.on("failure", (info) => {
metrics.increment("circuit.failure");
logger.warn("dependency failure", { error: info.error });
});
breaker.on("reject", (info) => {
metrics.increment("circuit.reject");
});Remove a listener with off():
breaker.off("stateChange", handler);Half-open probes
When the circuit is open and the reset timeout elapses, the next call transitions to half-open. One probe call (configurable with withHalfOpenMax()) is allowed through:
- If the probe succeeds, the circuit closes and normal traffic resumes.
- If the probe fails, the circuit reopens and the reset timeout restarts.
- Additional calls while the probe slot is occupied are rejected.
const breaker = new CircuitBreakerBuilder()
.forConsecutiveFailures({ threshold: 5 })
.withResetTimeout(30_000)
.withHalfOpenMax(1)
.build();Factory vs Builder
Use the Builder when you want readability and progressive configuration.
const breaker = new CircuitBreakerBuilder()
.forConsecutiveFailures({ threshold: 5 })
.withResetTimeout(30_000)
.withHalfOpenMax(1)
.isFailure((error) => error.message !== "not-a-real-error")
.build();Use createCircuitBreaker() when a plain config object is enough.
import { createCircuitBreaker } from "@xyph3r/circuit-breaker";
const breaker = createCircuitBreaker({
strategy: "consecutive",
threshold: 5,
resetTimeoutMs: 30_000,
});Custom strategies
Implement FailureDetectionStrategy to plug in your own failure detection logic.
import type { FailureDetectionStrategy } from "@xyph3r/circuit-breaker";
class JitteredFailureStrategy implements FailureDetectionStrategy {
recordSuccess(): void { /* ... */ }
recordFailure(): void { /* ... */ }
shouldOpen(): boolean { /* ... */ }
reset(): void { /* ... */ }
}
const breaker = new CircuitBreakerBuilder()
.useStrategy(new JitteredFailureStrategy())
.withResetTimeout(30_000)
.build();Public API
CircuitBreakerBuilder
.forConsecutiveFailures({ threshold }).forFailureRate({ threshold, windowMs, minSamples }).useStrategy(strategy).withResetTimeout(ms).withHalfOpenMax(max).isFailure(predicate).withClock(now).build()
CircuitBreaker
fire(fn, fallback?)wrap(fn, fallback?)state— current state:"closed","open", or"half-open"on(event, handler)off(event, handler)reset()
Events
stateChange—{ state, previousState, timestamp }success—{ state, timestamp }failure—{ state, error, timestamp }reject—{ state, error, timestamp }
Errors
CircuitBreakerOpenError— thrown when the circuit is open and no fallback is providedCircuitBreakerConfigurationError— thrown by the builder on invalid configuration
Publishing
The package is set up so npm publish builds dist/ during prepack and runs tests during prepublishOnly.
Release flow:
- Install dependencies with
bun installornpm install. - Verify with
bun run testandbun run build. - Inspect the tarball with
npm pack --dry-run. - Log in with
npm loginif needed. - Publish with
npm publish --access public --provenance.
License
MIT
