Skip to content
Buy me a coffee

Pipelines and strategy order

A pipeline is an ordered list of strategies. execute(callback) runs the callback through all of them. Build it once and reuse it: stateful strategies (circuit breaker, limiters, cache) keep their state in the pipeline, so a pipeline built per call never opens its breaker.

import {
class ResiliencePipelineBuilder<T = any, E = never, C extends string = never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
} from '@ylcnfrht/resilience';
const
const pipeline: ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'inventory' })
.
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 3,
RetryOptions<any>.backoffType?: BackoffType | undefined

Default 'constant'.

backoffType
: 'exponential' })
.
ResiliencePipelineBuilder<any, never, never>.addCircuitBreaker(options?: CircuitBreakerOptions<any>): Next<any, never, never, BrokenCircuitError>
addCircuitBreaker
({
CircuitBreakerOptions<any>.consecutiveFailures?: number | undefined

Break after this many failures in a row (a success resets the count). Simple and good for low traffic. When set without failureRatio, the ratio check is off and only this rule applies.

consecutiveFailures
: 5 })
.
ResiliencePipelineBuilder<any, BrokenCircuitError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, BrokenCircuitError, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('2s')
.
ResiliencePipelineBuilder<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BrokenCircuitError, never, TimeoutRejectedError>>
build
();

Both build the same pipeline. ResiliencePipelineBuilder has an addX() shortcut for every strategy and therefore bundles all of them (15.1 KB). PipelineBuilder only has add(), so only the xStrategy() definitions you import end up in your bundle. ResiliencePipelineBuilder also accepts add(xStrategy(...)), so the two mix freely.

Builder option Default Effect
name none Labels telemetry events (source.pipelineName)
timeProvider system clock Clock for every strategy; FakeTimeProvider in tests
unrefTimers false Pending timers don’t keep the process alive (CLIs, scripts). Ignored with timeProvider
tags none Labels added to every telemetry event of this pipeline
instrumentation none Hooks for this pipeline only, or false to opt out of all
telemetry true false emits no events from this pipeline

Options are validated, and strategy state is created, on build(). Each build() creates fresh state.

Order: the first strategy added is the outermost

Section titled “Order: the first strategy added is the outermost”

Each strategy wraps the ones added after it. The callback sits in the middle:

retry ─▶ circuit breaker ─▶ timeout ─▶ callback

The order changes what each strategy sees:

Order Behaviour
addRetry() → addTimeout('2s') Per-attempt timeout. Each attempt gets 2 s; a timed-out attempt is retried.
addTimeout('10s') → addRetry() Total budget. 10 s for all attempts and the delays between them. When it runs out, retrying stops.
addRetry() → addCircuitBreaker() The breaker counts every attempt. Once it opens, retry stops: an open circuit is never retried.
addCircuitBreaker() → addRetry() The breaker counts one outcome per execution, after retries are exhausted. It opens later.
addFallback() first The fallback catches everything inside, including CIRCUIT_OPEN and TIMEOUT.
addFallback() last The fallback only sees the callback’s own failures. Breaker and timeout errors pass it by.
Limiter first Retries don’t need a new permit. Limiter inside retry: each attempt queues again.

The common production shape combines both timeouts: a total budget outside, per-attempt timeouts inside.

const
const pipeline: ResiliencePipeline<any, never>
pipeline
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
()
.
ResiliencePipelineBuilder<any, never, never>.addFallback(options: FallbackOptions<any> & {
shouldHandle?: undefined;
}): ResiliencePipelineBuilder<any, never, string> (+1 overload)

Without shouldHandle it catches every failure of the strategies added after it, so their errors leave the error type.

addFallback
({
FallbackOptions<any>.fallbackValue?: any

A fixed substitute value (cockatiel's fallback(policy, value)).

fallbackValue
: null }) // last resort
.
ResiliencePipelineBuilder<any, never, string>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, string, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('10s') // total budget, retries included
.
ResiliencePipelineBuilder<any, never, string>.addRetry(options?: RetryOptions<any>): Next<any, never, string, TimeoutRejectedError>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 3,
RetryOptions<any>.backoffType?: BackoffType | undefined

Default 'constant'.

backoffType
: 'exponential',
RetryOptions<any>.useJitter?: Jitter | undefined
useJitter
: true })
.
ResiliencePipelineBuilder<any, never, string>.addCircuitBreaker(options?: CircuitBreakerOptions<any>): Next<any, never, string, BrokenCircuitError>
addCircuitBreaker
({
CircuitBreakerOptions<any>.failureRatio?: number | undefined

Failure ratio in the window that breaks the circuit. Default 0.1.

failureRatio
: 0.5,
CircuitBreakerOptions<any>.minimumThroughput?: number | undefined

Minimum recorded calls in the window before the ratio is considered. Default 100.

minimumThroughput
: 20 })
.
ResiliencePipelineBuilder<any, never, string>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, string, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('2s') // per attempt
.
ResiliencePipelineBuilder<any, never, string>.build(): ResiliencePipeline<any, never>
build
();
const
const a: Response
a
= await
const pipeline: ResiliencePipeline<any, never>
pipeline
.
ResiliencePipeline<any, never>.execute<Response>(callback: (context: ResilienceContext) => MaybePromise<Response>, options?: ExecuteOptions): Promise<Response>

Runs the callback through the pipeline; throws the final error.

execute
(({
signal: AbortSignal

Aborted when the caller cancels, or when a strategy (timeout, hedging) gives up on this execution.

signal
}) =>
function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)
fetch
('https://example.com', {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
})); // throws on failure
const
const b: Outcome<{
id: string;
}>
b
= await
const pipeline: ResiliencePipeline<any, never>
pipeline
.
ResiliencePipeline<any, never>.executeOutcome<{
id: string;
}>(callback: (context: ResilienceContext) => MaybePromise<{
id: string;
}>, options?: ExecuteOptions): Promise<Outcome<{
id: string;
}>>

Like execute, but returns the final outcome instead of throwing.

executeOutcome
(() =>
const api: {
getUser(id: string): Promise<{
id: string;
}>;
}
api
.
function getUser(id: string): Promise<{
id: string;
}>
getUser
('42')); // { ok: true, result } | { ok: false, error }
const
const c: Outcome<{
id: string;
}, UnexpectedError>
c
= await
const pipeline: ResiliencePipeline<any, never>
pipeline
.
ResiliencePipeline<any, never>.settle<{
id: string;
}, never>(callback: (context: ResilienceContext) => MaybePromise<{
id: string;
} | Outcome<{
id: string;
}, never>>, options?: ExecuteOptions): Promise<Outcome<{
id: string;
}, UnexpectedError>>

Like executeOutcome, but the error is typed: the errors this pipeline's strategies can raise (E), the errors your callback returns with Outcome.fromError(…) (E2), or UnexpectedError wrapping anything else (a thrown bug, a client error). Pair it with Outcome.match() for exhaustive handling:

const outcome = await pipeline.settle(async () => (user ? user : Outcome.fromError(new NotFound(id))));
Outcome.match(outcome, { ok: (u) => u, NOT_FOUND: () => null, TIMEOUT: () => cached, CIRCUIT_OPEN: () => cached, UNEXPECTED: (e) => { throw e } });

Returned errors count as failures for the strategies (they can be retried) unless your predicates say otherwise.

settle
(() =>
const api: {
getUser(id: string): Promise<{
id: string;
}>;
}
api
.
function getUser(id: string): Promise<{
id: string;
}>
getUser
('42')); // typed error union, see Typed errors
const
const getUser: (id: string) => Promise<{
id: string;
}>
getUser
=
const pipeline: ResiliencePipeline<any, never>
pipeline
.
ResiliencePipeline<any, never>.wrap<[id: string], {
id: string;
}>(fn: (id: string) => MaybePromise<{
id: string;
}>, options?: ExecuteOptions): (id: string) => Promise<{
id: string;
}>

const getUser = pipeline.wrap(api.getUser): a function with the same arguments that runs through the pipeline.

wrap
(
const api: {
getUser(id: string): Promise<{
id: string;
}>;
}
api
.
function getUser(id: string): Promise<{
id: string;
}>
getUser
.
CallableFunction.bind<(id: string) => Promise<{
id: string;
}>>(this: (id: string) => Promise<{
id: string;
}>, thisArg: unknown): (id: string) => Promise<{
id: string;
}> (+1 overload)

For a given function, creates a bound function that has the same body as the original function. The this object of the bound function is associated with the specified object, and has the specified initial parameters.

@param ― thisArg The object to be used as the this object.

bind
(
const api: {
getUser(id: string): Promise<{
id: string;
}>;
}
api
)); // same signature, runs through the pipeline
await
const getUser: (id: string) => Promise<{
id: string;
}>
getUser
('42');

wrap() doesn’t pass the signal to the wrapped function. Use getResilienceSignal() inside it, or call execute directly.

addPipeline(pipeline) inserts an existing pipeline as one strategy, with its state. Use it to share a circuit breaker or limiter between pipelines that call the same dependency:

const
const paymentsBreaker: ResiliencePipeline<any, BrokenCircuitError>
paymentsBreaker
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'payments-breaker' })
.
ResiliencePipelineBuilder<any, never, never>.addCircuitBreaker(options?: CircuitBreakerOptions<any>): Next<any, never, never, BrokenCircuitError>
addCircuitBreaker
({
CircuitBreakerOptions<any>.consecutiveFailures?: number | undefined

Break after this many failures in a row (a success resets the count). Simple and good for low traffic. When set without failureRatio, the ratio check is off and only this rule applies.

consecutiveFailures
: 5 })
.
ResiliencePipelineBuilder<any, BrokenCircuitError, never>.build(): ResiliencePipeline<any, BrokenCircuitError>
build
();
const
const charge: ResiliencePipeline<any, WithErrors<TimeoutRejectedError, never, BrokenCircuitError>>
charge
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'charge' })
.
ResiliencePipelineBuilder<any, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
('5s')
.
ResiliencePipelineBuilder<any, TimeoutRejectedError, never>.addPipeline<BrokenCircuitError>(pipeline: ResiliencePipeline<any, BrokenCircuitError>): Next<any, TimeoutRejectedError, never, BrokenCircuitError>

Nest an existing pipeline (and its state) as a single strategy.

addPipeline
(
const paymentsBreaker: ResiliencePipeline<any, BrokenCircuitError>
paymentsBreaker
)
.
ResiliencePipelineBuilder<any, WithErrors<TimeoutRejectedError, never, BrokenCircuitError>, never>.build(): ResiliencePipeline<any, WithErrors<TimeoutRejectedError, never, BrokenCircuitError>>
build
();
const
const refund: ResiliencePipeline<any, BrokenCircuitError>
refund
= new
new ResiliencePipelineBuilder<any, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<any, never, never>

Polly-style builder with a shortcut for every strategy: addRetry(), addTimeout(), addCircuitBreaker(), … Convenient, but it bundles every strategy. For the smallest browser bundle use PipelineBuilder with retryStrategy(), timeoutStrategy(), … instead; the two are interchangeable.

The type parameters track which errors the pipeline can raise, for pipeline.settle() + Outcome.match().

ResiliencePipelineBuilder
({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'refund' })
.
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 2 })
.
ResiliencePipelineBuilder<any, never, never>.addPipeline<BrokenCircuitError>(pipeline: ResiliencePipeline<any, BrokenCircuitError>): Next<any, never, never, BrokenCircuitError>

Nest an existing pipeline (and its state) as a single strategy.

addPipeline
(
const paymentsBreaker: ResiliencePipeline<any, BrokenCircuitError>
paymentsBreaker
)
.
ResiliencePipelineBuilder<any, BrokenCircuitError, never>.build(): ResiliencePipeline<any, BrokenCircuitError>
build
();

The nested pipeline’s strategies run in place; its own kill switch and dispose() are not consulted by the outer pipeline. Dispose it yourself.

A pipeline executed inside the callback of another one is different: it runs as an independent call but inherits the outer signal. That is deadline propagation.

ResiliencePipelineRegistry builds each keyed pipeline once and returns the same instance afterwards:

const
const registry: ResiliencePipelineRegistry<string>
registry
= new
new ResiliencePipelineRegistry<string>(options?: {
timeProvider?: TimeProvider;
pipelineName?: ((key: string) => string) | undefined;
}): ResiliencePipelineRegistry<string>

Builds each keyed pipeline once and caches it, so stateful strategies (circuit breakers, limiters) are shared.

ResiliencePipelineRegistry
();
const
const inventory: ResiliencePipeline<any, any>
inventory
=
const registry: ResiliencePipelineRegistry<string>
registry
.
ResiliencePipelineRegistry<string>.getOrAddPipeline<any>(key: string, configure: ConfigurePipeline<string>): ResiliencePipeline<any, any>
getOrAddPipeline
('inventory-api', (
b: ResiliencePipelineBuilder<any, never, never>
b
) =>
b: ResiliencePipelineBuilder<any, never, never>
b
.
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
().
ResiliencePipelineBuilder<any, never, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, never, never, TimeoutRejectedError>

addTimeout(5000), addTimeout('5s') or addTimeout({ timeout: '5s', onTimeout }).

addTimeout
(1000));
const registry: ResiliencePipelineRegistry<string>
registry
.
ResiliencePipelineRegistry<string>.reload(key: string, configure?: ConfigurePipeline<string> | undefined): void

Rebuild key's pipeline, optionally with a new configuration (e.g. limits from a feature flag). Existing references keep working and pick up the new pipeline; the old one is disposed and its state dropped.

reload
('inventory-api', (
b: ResiliencePipelineBuilder<any, never, never>
b
) =>
b: ResiliencePipelineBuilder<any, never, never>
b
.
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.maxRetryAttempts?: number | undefined

Retries in addition to the original call. Default 3. Infinity retries until aborted.

maxRetryAttempts
: 5 })); // `inventory` now runs the new config
const registry: ResiliencePipelineRegistry<string>
registry
.
ResiliencePipelineRegistry<string>.circuitBreakers(): RegisteredStrategy<string>[]

Every circuit breaker in the registry's pipelines, e.g. for a health endpoint: strategy.state, strategy.metrics.

circuitBreakers
(); // every breaker: key, name, live strategy (for a health endpoint)
const registry: ResiliencePipelineRegistry<string>
registry
.
ResiliencePipelineRegistry<string>.limiters(): RegisteredStrategy<string>[]

Every concurrency / rate limiter, e.g. to report active / queued / limit.

limiters
(); // every limiter
const registry: ResiliencePipelineRegistry<string>
registry
.
ResiliencePipelineRegistry<string>.dispose(): void

Dispose every pipeline (background timers, store sync). The registry can't be used afterwards.

dispose
(); // on shutdown
  • reload() swaps the pipeline underneath existing references. The old one is disposed and its state dropped.
  • tryAddBuilder(key, configure) registers a builder lazily; getPipeline(key) throws if none exists.
  • The registry’s pipelines are named after their key (override with pipelineName: (key) => string).
const pipeline: ResiliencePipeline<any, BrokenCircuitError>
pipeline
.
ResiliencePipeline<any, BrokenCircuitError>.disable(): void

Kill switch: while disabled, calls bypass every strategy (no retries, no breaker, no limits).

disable
(); // every call bypasses every strategy (no retries, no breaker, no limits)
const pipeline: ResiliencePipeline<any, BrokenCircuitError>
pipeline
.
ResiliencePipeline<any, BrokenCircuitError>.enable(): void
enable
();
const pipeline: ResiliencePipeline<any, BrokenCircuitError>
pipeline
.
ResiliencePipeline<any, BrokenCircuitError>.describe(): StrategyDescriptor[]

The strategies in execution order (outermost first), e.g. to assert a pipeline's configuration in tests.

describe
(); // [{ name: 'Retry', kind: 'RetryStrategy', strategy }, { name: 'CircuitBreaker', … }]
const
const breaker: CircuitBreakerStrategy<unknown> | undefined
breaker
=
const pipeline: ResiliencePipeline<any, BrokenCircuitError>
pipeline
.
ResiliencePipeline<any, BrokenCircuitError>.getStrategy<CircuitBreakerStrategy<unknown>>(nameOrKind: string): CircuitBreakerStrategy<unknown> | undefined

The strategy instance named name (or of class kind), to read its live state: getStrategy<CircuitBreakerStrategy>('CircuitBreaker')?.state.

getStrategy
<
class CircuitBreakerStrategy<T>
CircuitBreakerStrategy
<unknown>>('CircuitBreaker');
const breaker: CircuitBreakerStrategy<unknown> | undefined
breaker
?.
CircuitBreakerStrategy<unknown>.state: CircuitState | undefined

Current state.

state
; // 'closed' | 'open' | 'half-open' | 'isolated'
await
const breaker: CircuitBreakerStrategy<unknown> | undefined
breaker
?.
CircuitBreakerStrategy<unknown>.trip(): Promise<void>

Open the circuit now for its normal break duration (unlike isolate, it half-opens by itself afterwards).

trip
();
const pipeline: ResiliencePipeline<any, BrokenCircuitError>
pipeline
.
ResiliencePipeline<any, BrokenCircuitError>.dispose(): void

Stops background work (health checks, store sync timers). Later executions fail.

dispose
(); // stops health checks and store sync timers
  • getStrategy() matches the strategy’s name (default 'Retry', 'CircuitBreaker', 'Timeout'…, or the name option) or its class name. Give two strategies of the same kind distinct names.
  • After dispose(), execute() rejects with a plain Error and executeOutcome() returns it as a failure.