@prash0029/circuit-breaker
v0.1.0
Published
Two-level, config-driven in-memory circuit breaker for guarding outbound server API calls (per-service + per-endpoint).
Maintainers
Readme
circuit-breaker
Two-level, config-driven, in-memory circuit breaker for guarding outbound server API calls. Built once at server start from a route checklist, then placed in front of every outgoing request (axios, fetch, or any promise-returning call).
Each request is matched against the rules to find its service rule and its endpoint rule. The request passes only if both matched breakers are non-open. The response status then classifies the call as success or failure on each level:
- failure ->
count + 1 - success ->
count - 1(floored at 0; leaky bucket, not a hard reset) count >= failureThresholdtripsCLOSED -> OPEN
A request matching no rule passes through untouched.
States
CLOSED ──count >= failureThreshold──▶ OPEN
OPEN ──cooldownMs elapsed──▶ HALF_OPEN
HALF_OPEN ──successThreshold trial successes──▶ CLOSED
HALF_OPEN ──any trial failure──▶ OPENInstall
npm install circuit-breakerConfigure once at startup
import { createBreaker } from "circuit-breaker";
export const breaker = createBreaker({
// evaluated in order; first match wins per level
rules: [
{
type: "service",
match: "insurer-x.com", // substring tested against the request path
skipList: ["/health", "/ping"], // endpoints NOT counted toward the service
failureThreshold: 10,
cooldownMs: 30_000,
},
{
type: "endpoint",
match: "/policies/create",
failureThreshold: 5,
cooldownMs: 30_000,
successThreshold: 2,
successServerStatus: [200, 201], // optional; else default 2xx = success
failedServerResStatus: [500, 502, 503],
// per-rule hook: runs only for THIS endpoint's transitions
onStateChange: (e) => {
if (e.to === "OPEN") alertOncall(`createPolicy breaker open`);
},
},
],
defaults: { failureThreshold: 5, cooldownMs: 30_000, successThreshold: 2 },
matchOn: "path", // default: strips query string so tokens/PII are never matched
onStateChange: (e) =>
console.warn(`[breaker] ${e.level} "${e.key}" ${e.from} -> ${e.to}`),
});Rule fields
| Field | Applies to | Meaning |
| ----------------------- | ---------------- | ------------------------------------------------------------- |
| type | both | "service" or "endpoint" |
| match | both | substring searched in the request path; rule applies if found |
| skipList | service | endpoint substrings excluded from the service's count |
| successServerStatus | both (optional) | statuses counted as success |
| failedServerResStatus | both (optional) | statuses counted as failure |
| failureThreshold | both (optional) | count that opens the breaker |
| cooldownMs | both (optional) | OPEN duration before a HALF_OPEN probe |
| successThreshold | both (optional) | trial successes needed to close |
Status classification precedence: failedServerResStatus -> successServerStatus -> default (defaultIsSuccess, 2xx). A status with no response (network error) is always a failure.
Use it
axios (guards the whole instance)
import axios from "axios";
import { attachAxios } from "circuit-breaker";
const client = axios.create({ baseURL: "https://insurer-x.com" });
attachAxios(client, breaker);
await client.post("/policies/create", payload); // guarded; rejects if openfetch
import { wrapFetch } from "circuit-breaker";
const gfetch = wrapFetch(breaker); // wraps global fetch (Node 18+)
const res = await gfetch("https://insurer-x.com/policies/create", { method: "POST", body });Any promise-returning call
import { isCircuitOpenError } from "circuit-breaker";
try {
const result = await breaker.guard(
{ url: "https://insurer-x.com/policies/create", method: "POST" },
() => doRequest(), // must resolve to something with a numeric `status`
);
} catch (err) {
if (isCircuitOpenError(err)) {
// fail fast: queue, fall back, or return 503. err.retryAt = when to retry.
} else {
throw err;
}
}
// Or wrap once:
const createPolicy = breaker.wrap(
{ url: "https://insurer-x.com/policies/create", method: "POST" },
(body) => doRequest(body),
);
await createPolicy(body);Inspect / operate
breaker.stateOf("service", "insurer-x.com"); // { state, count, openedAt } | null
breaker.stateOf("endpoint", "/policies/create");
breaker.reset("endpoint", "/policies/create"); // force one breaker CLOSED
breaker.resetAll(); // force everything CLOSED
breaker.snapshots(); // { service: {key: snap}, endpoint: {key: snap} }
breaker.printStatus(); // one line per live breaker -> console.log
breaker.printStatus(log.info); // ...or to your own logger sinkState-change callbacks
Two levels, both optional, both receive { level, key, from, to, at }:
- registry-wide
onStateChangein the config — every breaker. - per-rule
onStateChangeon a rule — only that endpoint/service. Runs first, then the registry-wide one.
Email alerts (nodemailer)
emailAlerter builds an onStateChange handler that mails an alert when a breaker trips. The transporter is injected — SMTP credentials stay in your env/secrets manager, never in this package. Default trigger: OPEN only. Recipients come from each rule's alertEmails (falling back to to). Sends are non-blocking and never throw into the breaker (failures are caught + logged).
import nodemailer from "nodemailer";
import { createBreaker, emailAlerter } from "circuit-breaker";
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS }, // from env, not code
});
const alert = emailAlerter({
transporter,
from: "[email protected]",
// when: [BreakerState.OPEN] // default
// to: ["[email protected]"] // fallback if a rule omits alertEmails
});
const breaker = createBreaker({
rules: [
{ type: "endpoint", match: "/policies/create", failureThreshold: 5,
alertEmails: ["[email protected]", "[email protected]"] }, // per-row list
{ type: "service", match: "icicilombard.com", failureThreshold: 10,
alertEmails: ["[email protected]"] },
],
onStateChange: alert, // attach registry-wide; fires for every rule
});Each rule mails its own alertEmails list. Use subject/body options to customize the message.
Scope / limits
- In-memory per process. Behind multiple replicas, each replica trips independently. No shared/distributed state.
- HALF_OPEN admits concurrent probes (no in-flight reservation); a burst right after cooldown can all probe at once.
- No timeout wrapping. Wrap your own request timeout inside the call.
- Substring matching is loose.
"/policy"also matches"/policy-archive". Order rules deliberately (first match wins) and prefer specificmatchstrings. - A cancelled axios request that already passed the open check records no outcome.
Develop
npm install
npm test
npm run build