Skip to content

Getting started

bash
npm i resilix

resilix has no runtime dependencies, ships ESM and CJS, and runs on Node 18/20/22/24, Bun, Deno and Cloudflare Workers.

Quick start

ts
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:

ts
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 / minCalls15 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:

ts
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:

  • pg pool exhaustion has no error code at all — a bare Error reading "timeout exceeded when trying to connect". Misread as transient, 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 its P2010 is ambiguous — the same code wraps a syntax error, a missing column and a statement timeout. classifySql unwraps it; classifying on P2010 alone calls a timeout transient.
  • PrismaClientValidationError carries no code, only a name. It is the caller passing the wrong type, so it is answered and 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.

MIT licensed. Zero runtime dependencies.