Getting started
npm i resilixresilix has no runtime dependencies, ships ESM and CJS, and runs on Node 18/20/22/24, Bun, Deno and Cloudflare Workers.
Quick start
import { pipeline, breaker, classifyHttp } from "resilix";
const api = pipeline<Request>({
key: (req) => new URL(req.url).host, // one breaker per host
classify: classifyHttp,
timeoutMs: 15_000,
policies: [
breaker({
slowCallMs: 3_000, // required: ~3x your healthy p95
slowCallRate: 0.5, // trip if >50% of calls are slow
failureRate: 0.5, // ...or if >50% fail
consecutiveBackstop: 10, // ...or 10 in a row, at any traffic level
}),
],
});
const res = await api.execute(req, (ctx) => fetch(req.url, { signal: ctx.signal }));Refused calls throw RejectedError with a reason and, where known, retryAfterMs.
Streaming: mark the latency that matters
A call's latency defaults to its total duration. That is wrong for anything streaming — a 45-second LLM completion is healthy if the first token arrived in 300 ms. Judge it on total duration and every healthy stream looks slow, so the slow-call breaker opens on a perfectly good upstream. Call ctx.mark() at the moment that actually indicates health:
await api.execute(req, async (ctx) => {
const res = await fetch(url, { signal: ctx.signal });
ctx.mark(); // time to first token — the health signal
return consumeStream(res); // may run for another 45s; not counted
});Watch for a starved window
A window bounded by age holds at most maxAgeMs / callDuration samples, so an upstream whose calls take longer than maxAgeMs / minCalls — 15 s at the defaults — can never reach minCalls. That used to leave both rate conditions permanently inert.
Two things prevent it now. The age bound is widened automatically to minCalls × slowCallMs when the configured one is too narrow to hold that many samples — read breaker.stats().effectiveMaxAgeMs for the value in force. And once the age bound is actually evicting, the breaker will decide on what it has (down to 5 samples) rather than waiting for minCalls that can never arrive: every sample that exists is already in the window.
breaker.stats().starved and the resilix.breaker.starved gauge still report when the window is running below minCalls, which is worth alerting on as a sign your slowCallMs and window bounds do not match your workload.
Guarding a database
classifyHttp is wrong for SQL: it calls a unique-violation transient, so a burst of duplicate inserts looks like the database falling over. Use classifySql:
import { classifySql } from "resilix";
pipeline({ classify: classifySql, policies: [bulkhead({ concurrency: 10 }), breaker({ ... })] });Every mapping was verified against real errors from pg 8.23 and Prisma 7.9 on PostgreSQL 16, not from documentation. Three things only showed up that way:
pgpool exhaustion has no error code at all — a bareErrorreading"timeout exceeded when trying to connect". Misread astransient, a burst that exhausts your pool would open the circuit, when the database is healthy and you should shed load.- Prisma 7 nests the real SQLSTATE at
meta.driverAdapterError.cause.originalCode, and itsP2010is ambiguous — the same code wraps a syntax error, a missing column and a statement timeout.classifySqlunwraps it; classifying onP2010alone calls a timeouttransient. PrismaClientValidationErrorcarries no code, only a name. It is the caller passing the wrong type, so it isansweredand must never open a circuit.
Run pnpm test:integration with a Postgres to re-verify after a driver upgrade; the captured fixtures alone would keep passing if a shape changed.
Next
Everything above rests on one idea — that an outcome is classified into a verdict rather than reduced to pass/fail. That is the verdict model, and it is worth reading before configuring anything.