
Firefly is for projects that will never have a service mesh: retries, deadlines, circuit breakers, bulkheads, rate limits and hedges for the calls one process makes, with every number chosen by you.
It is a library, not a sidecar. You describe an upstream once, hand it the calls you make, and it decides what happens when they fail: how many times to try, how long to wait first, and when to stop trying at all.
No runtime dependencies, and nothing happens at import time.
shouldRetry has no default. A 409 is fatal to one caller and expected by another, and the types will not let you skip the question.fetch replacement, a cache or a metrics pipeline. It performs no I/O, stores no responses and writes nothing anywhere. onEvent hands you the decisions and the rest is yours.A policy is a function that wraps a unit of work and hands back something of the same shape:
type Action<T> = (signal: AbortSignal) => Promise<T>;
type Policy = <T>(action: Action<T>) => Wrapped<T>;
Because what goes in matches what comes out, policies nest, and a stack of them is still one function you call. retry around timeout around your work is a retry of a deadline.
Four things follow from that shape:
fetch, a database query, anything else returning a promise.AbortSignal, so a deadline aborts the attempt instead of only giving up on waiting for it. An action that watches its signal stops working.The order the policies go in has one right answer, so Dependency assembles it for you, and stack throws on an arrangement that cannot work.
npm install firefly-limiter
1. Describe the thing you call. A Dependency is one upstream and every decision you have made about calling it. Build it once, next to the client it protects, and share it.
import { Dependency, exponential, retryAnything } from "firefly-limiter";
export const rates = new Dependency({
name: "rates",
attempts: 3,
backoff: exponential({ base: 200, max: 5_000 }),
shouldRetry: retryAnything,
deadline: 2_000,
breaker: { threshold: 5, resetAfter: 30_000 },
});
Every field is required. There is no default attempt count and no default deadline.
2. Call through it.
const today = await rates.run((signal) => readRates(signal));
3. Decide the per-call parts at the call. Two things belong to one call rather than to the dependency, so they are options on run:
const today = await rates.run(readRates, {
share: "today", // twenty callers, one request
fallback: () => cached, // an answer for a call that failed all the way through
});
4. Or hand the same policy to an HTTP client. transport wraps a fetch-shaped function, so a client never imports Firefly:
import { retryableTransportError, transport } from "firefly-limiter/http";
const api = new ApiClient({
baseUrl: "https://api.acme.com/v1",
transport: transport(fetch, rates.policy),
});
Inside a transport a 429 arrives as an ordinary resolved Response, so use retryableTransportError as that dependency's shouldRetry.
5. Read what it is doing. Reading state changes nothing, so a health endpoint can call it:
app.get("/health", () => rates.health());
// { name: "rates", circuit: "closed", failures: 0, inFlight: 0, queued: 0 }
Guides and the full API reference: https://smiduweorc.github.io/firefly/
| Guide | What it covers |
|---|---|
| Concepts | Actions, policies, stack order, signals, clocks, events, errors |
| Dependency | The front door: every option, per-call options, health |
| Policies | Each policy on its own, and the numbers that matter |
| HTTP | firefly-limiter/http, body replay, Retry-After, Aphid and dung beetle |
| Testing | Virtual clocks, and asserting on decisions |
| Boundaries | What this package will not do, and where that work goes |
The guide sources live in guides/.
Firefly is the third of three packages that share one boundary. Aphid and dung beetle describe an API and report what happened. Firefly is where "what to do about it" lives, and it depends on neither at build time or run time.
It is built for one process. Boundaries has the full list of what that rules out.
| Script | What it does |
|---|---|
npm run build |
Compile src/ and the barrels to dist/ with type declarations. |
npm run typecheck |
Type-check the package and the tests without emitting. |
npm run lint |
Run ESLint. |
npm test |
Run the test suite with the Node test runner via tsx. |
npm run test:dist |
Build, then run the tests that import dist/. |
npm run docs |
Build the documentation site into docs/. |
npm run changelog |
Regenerate CHANGELOG.md from the commit history. |
Publishing and deployment are handled manually (custom npm settings), so no release/publish workflow is included here.
MIT