Dependency is one upstream and everything you have decided about calling it.
It is the front door, and for most services it is the only thing you need to
construct.
It exists because a policy stack assembled per call site puts the state in the wrong place. A breaker built inside a client factory is a breaker per client, and a breaker that only some call sites go through is not measuring the dependency. Here the state and the scope are the same object: one per upstream, constructed where you can see it, shared by everything that talks to it.
import { Dependency, exponential, retryAnything } from "firefly-limiter";
export const payments = new Dependency({
name: "payments",
attempts: 3,
backoff: exponential({ base: 200, max: 10_000 }),
shouldRetry: retryAnything,
deadline: 5_000,
breaker: { threshold: 5, resetAfter: 30_000 },
});
The numbers are yours. The order is not, so it is assembled here where it cannot be got wrong:
retry -> hedge? -> breaker? -> limiter? -> bulkhead? -> timeout
Anything you did not configure is left out of the stack rather than included
with a default. onEvent and clock are passed down to every piece, so you
set them once.
Two policies sit outside all of that and are decided per call, because they
cannot be decided per dependency: share needs something to share on, and
fallback produces a result whose type belongs to one call.
Every one of these is a decision only you can make, so none has a default.
| Option | What it is |
|---|---|
name |
What this dependency is called, in health() output and your own logs. |
attempts |
Total attempts per call, not additional ones. 1 never retries. |
backoff |
How long to wait between attempts. See Policies. |
shouldRetry |
Whether a failure is worth repeating. |
deadline |
Deadline for one attempt, in milliseconds. |
breaker |
When to stop calling this dependency, or false. |
breaker: false is not the same as leaving the field out, because the field
cannot be left out. It says you decided against a breaker rather than forgot
one.
shouldRetry has no default either. Whether a rejection is worth repeating is
the one thing this package cannot know: a 409 is fatal to one caller and
expected by another. retryAnything is how you say "repeat everything" and
mean it. Under a firefly-limiter/http transport, use retryableTransportError, which
already classified the response.
| Option | Add it when |
|---|---|
bulkhead |
One slow upstream must not consume every socket or worker. |
rateLimit |
The API publishes a rate limit, or you are being polite. |
budget |
Retries must not multiply an outage across concurrent callers. |
hedge |
The p99 is the problem rather than the failures. |
maxElapsed |
A whole call, across all its attempts, has a time budget. |
onEvent |
You want the decisions in your metrics or logs. |
clock |
You are testing, or you keep time yourself. |
budget is shared by the retries and the hedged attempts, so the two cannot
quietly add up to several times the load. Each option's own fields are
documented on its policy in Policies; the onEvent and
clock fields are omitted from them here because the dependency supplies both.
run takes the action and returns the result:
const invoice = await payments.run((signal) => charge(id, signal));
wrap returns the wrapped action instead, for handing somewhere that will call
it later. The signal is passed at call time rather than at wrap time, because
it belongs to the call and a wrapped action can be called more than once:
const charge = payments.wrap((signal) => post(id, signal));
queue.push(charge);
const today = await rates.run(readRates, {
share: "today",
fallback: () => cached,
signal: request.signal,
});
share collapses this call into any other call to the same dependency running
under the same key, so twenty callers wanting one thing make one request and
share its attempts. Nothing is kept once the call settles, so the caller after
them starts a new one.
fallback answers with something rather than failing: stale data, an empty
list, a degraded result. It goes outside everything, so it sees Firefly's own
refusals too, and an open circuit becomes stale data instead of an exception.
The handler receives the failure, and throwing from it declines to answer for
that one.
signal abandons this call.
health() reports what the dependency is doing, in a shape a health endpoint
can return. Reading it changes nothing:
app.get("/health", () => payments.health());
{
name: "payments",
circuit: "closed", // or "open", "half-open", or "none" if breaker: false
failures: 0, // what the breaker is currently counting
inFlight: 0,
queued: 0,
tokens: 10, // only when there is a rate limiter
retries: 10, // only when there is a retry budget
}
The pieces themselves are readable as well, for anything health() does not
cover: payments.breaker, .bulkhead, .limiter, .budget (each undefined
when not configured), .shared for the single-flight, and .options for the
options it was built from. .policy is everything that applies to every call,
as one policy, which is what you hand to a transport.
stackDependency is one stack in one order. Reach past it when you need a different
arrangement, when two dependencies must share a breaker, or when the work is
not shaped like a service call. Nothing is lost by doing so: Dependency is
built out of the same public pieces, and stack still checks the order.