Resilience Pipelines with cockatiel (circuit breaker, timeout, fallback)
Run a Benzene middleware pipeline (or an outbound port call) through a
cockatiel IPolicy, so you get circuit breaker / timeout /
bulkhead / fallback on top of retry — without Benzene wrapping or hiding cockatiel behind its own
abstraction.
Port note. The .NET original adapts Polly in
Benzene.Resilience.Polly. The TypeScript port adapts cockatiel — the closest JS analogue — as@benzene/cockatiel, under the "third-party integrations are adapted, not reimplemented" convention. Where Polly exposes a mutableResiliencePipelineBuilder, cockatiel composes policies functionally (wrap(retry(...), circuitBreaker(...))), so you build theIPolicyyou want and pass it in — there is no separate builder to configure.
Problem statement
@benzene/resilience ships exactly one resilience pattern in-box: retry with exponential backoff
(useRetry / RetryMiddleware<TContext>) — see Resilience. It deliberately does
not ship a circuit breaker, timeout, or bulkhead, and has zero runtime dependencies, so it stays the
lightweight option for callers who only want retry.
Everything else — circuit breaker, timeout, bulkhead, fallback, and compositions — comes from the sibling
@benzene/cockatiel package. It takes a cockatiel dependency in exchange for the whole toolkit, and
it exposes cockatiel rather than wrapping it: you build a policy with exactly the strategies you want and
hand it to useResiliencePipeline(...).
Prerequisites
- Node.js 22+.
- A Benzene middleware pipeline (any transport) built with
IMiddlewarePipelineBuilder<TContext>. The examples below use an AWS Lambda SQS pipeline — see Handling SQS Message Failures. - Familiarity with building a cockatiel policy via
retry/circuitBreaker/timeout/wrap— this cookbook doesn't re-teach cockatiel; see its docs for the full strategy catalogue.
Installation
npm install @benzene/cockatiel cockatiel
@benzene/cockatiel declares cockatiel as its one runtime dependency (npm pulls it in transitively, but
install it explicitly so you can import retry/circuitBreaker/etc. yourself). For retry-only with no
dependency, use @benzene/resilience instead — see Just retry.
Step 1 — build a policy with the strategies you need
Compose whatever cockatiel strategies your service needs — timeout and circuit breaker in this example,
combined with wrap (the outermost policy is listed first):
import {
wrap,
timeout,
circuitBreaker,
handleAll,
TimeoutStrategy,
ConsecutiveBreaker,
} from 'cockatiel';
const policy = wrap(
// Bound how long we WAIT for a result to 5s (see the timeout caveat below).
timeout(5_000, TimeoutStrategy.Aggressive),
// Trip the breaker after 5 consecutive failures; try again after 15s.
circuitBreaker(handleAll, {
halfOpenAfter: 15_000,
breaker: new ConsecutiveBreaker(5),
}),
);
Build a stateful policy like a circuit breaker once and reuse it — the breaker's open/closed state lives on the instance, so building a fresh one per message would defeat it. Construct the policy at module scope (or hold it as a singleton in DI) and pass the same instance into the pipeline.
Step 2 — wire it into the pipeline
useResiliencePipeline(app, policy) runs everything nested after it through the policy. It is a free
function taking the pipeline builder first (the port's shape for what C# writes as a fluent extension
method) and returns the builder for chaining:
import { InlineAwsLambdaStartUp, toLambdaHandler } from '@benzene/aws-lambda-core';
import { useSqs, SqsMessageContext } from '@benzene/aws-lambda-sqs';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useResiliencePipeline } from '@benzene/cockatiel';
import { ProcessOrderHandler } from './ProcessOrderHandler.js';
const entryPoint = new InlineAwsLambdaStartUp()
.configureServices((services) => {
addBenzene(services);
})
.configure((app) =>
useSqs(app, (sqs) => {
useResiliencePipeline<SqsMessageContext>(sqs, policy); // <-- circuit breaker + timeout here
useMessageHandlers(sqs, ProcessOrderHandler);
}),
)
.build();
export const handler = toLambdaHandler(entryPoint);
useResiliencePipeline is fully generic, like every other Benzene middleware, so it works on any pipeline
context — inbound transport contexts and the outbound OutboundContext alike (see
Outbound clients).
Step 3 — outcome awareness: acting on a returned failure result
Benzene reports domain failure two ways: a thrown error, or an unsuccessful result left on the context (not thrown) — see Message Results. cockatiel's strategies fire on thrown errors, so by default a returned failure result is invisible to them.
Pass an isFailure predicate to bridge the two. After the pipeline runs, if the predicate returns true,
the middleware throws an internal BenzeneFailureResultException that cockatiel can treat as a handled
outcome — but only if you configure the policy to handle it with handleType:
import { useResiliencePipeline, BenzeneFailureResultException } from '@benzene/cockatiel';
import { retry, handleType, ExponentialBackoff } from 'cockatiel';
import { useSqs, SqsMessageContext } from '@benzene/aws-lambda-sqs';
useSqs(app, (sqs) => {
useResiliencePipeline<SqsMessageContext>(
sqs,
retry(handleType(BenzeneFailureResultException), {
maxAttempts: 3,
backoff: new ExponentialBackoff(),
}),
// SqsMessageContext exposes `isSuccessful` (set by the result-setter) — retry a failed record.
(context) => context.isSuccessful === false,
);
useMessageHandlers(sqs, ProcessOrderHandler);
});
The sentinel never escapes: once the policy finishes (retries exhausted, breaker open, …), it is
swallowed and the last unsuccessful result remains on the context — identical to running with no resilience
middleware. A real error is never wrapped and propagates normally (including cockatiel's own
BrokenCircuitError when a breaker is open and TaskCancelledError from a timeout policy). With no
isFailure (the default), only thrown errors drive the strategies.
Step 4 — outbound clients: the same middleware
Because useResiliencePipeline(...) is fully generic, it works on an outbound route exactly the way
@benzene/resilience's inbound useRetry does — this is the higher-value case, since Benzene's whole
thesis is wrapping port calls. Add it to an outbound route's pipeline:
import { addOutboundRouting, OutboundContext } from '@benzene/clients';
import { useResiliencePipeline } from '@benzene/cockatiel';
addOutboundRouting(services, (routing) =>
routing.route('order:create', (pipeline) => {
useResiliencePipeline<OutboundContext>(pipeline, policy); // reuse the shared breaker instance
pipeline.useService(PublishOrderCreated); // your terminal send middleware
}),
);
Timeout caveat
Benzene's IMiddleware seam carries no cancellation token, so the middleware calls next() without
threading cockatiel's per-execution AbortSignal. A timeout policy therefore rejects on time
(TaskCancelledError, which propagates) but does not actually abort the in-flight handler — next()
runs to completion in the background. Use timeout to bound how long you wait for a result, not to
cancel work already started.
Testing
A cockatiel policy is a real object you can construct directly in a test — and
CockatielResilienceMiddleware<TContext> is a plain IMiddleware<TContext> you can drive without a host.
Here retry re-runs next() until it stops throwing:
import { describe, expect, it } from 'vitest';
import { retry, handleAll } from 'cockatiel';
import { CockatielResilienceMiddleware } from '@benzene/cockatiel';
describe('cockatiel resilience middleware', () => {
it('retries a throwing pipeline until it succeeds', async () => {
const policy = retry(handleAll, { maxAttempts: 3 });
const middleware = new CockatielResilienceMiddleware<object>(policy);
let attempts = 0;
await middleware.handleAsync({}, () => {
attempts++;
if (attempts < 3) {
throw new Error('transient');
}
return Promise.resolve();
});
expect(attempts).toBe(3); // failed twice, succeeded on the third attempt
});
it('surfaces a failure RESULT as a handled outcome when isFailure is supplied', async () => {
const { BenzeneFailureResultException } = await import('@benzene/cockatiel');
const policy = retry(handleType(BenzeneFailureResultException), { maxAttempts: 3 });
const context = { isSuccessful: false as boolean };
const middleware = new CockatielResilienceMiddleware<typeof context>(
policy,
(ctx) => ctx.isSuccessful === false,
);
let attempts = 0;
await middleware.handleAsync(context, () => {
attempts++;
return Promise.resolve(); // never throws — failure is a result on the context
});
expect(attempts).toBe(3); // the sentinel drove the retries…
expect(context.isSuccessful).toBe(false); // …and never escaped
});
});
handleType here comes from cockatiel. See test/Benzene.Core.Test/Cockatiel/ for the full set
(retry, exception propagation, and the outcome-aware failure-result path).
Just retry, no cockatiel dependency
If you only want retry, don't take the cockatiel dependency — use @benzene/resilience, the
zero-dependency hand-rolled retry middleware:
npm install @benzene/resilience
import { useRetry } from '@benzene/resilience';
import { useMessageHandlers } from '@benzene/core-message-handlers';
useRetry(app, { numberOfRetries: 3, initialDelayMs: 200, backoffFactor: 2.0 });
useMessageHandlers(app, ProcessOrderHandler);
useRetry retries on a thrown error and, via a shouldRetryContext predicate, on a failure result left on
the context. Don't stack useRetry and useResiliencePipeline for the same concern — put retry inside
the cockatiel policy (retry composed with your breaker via wrap) so cockatiel's strategies compose
correctly (a breaker sees the retries as one logical call). See Resilience for the full
RetryOptions reference.
Troubleshooting
- My returned failure result isn't being acted on. Supplying
isFailureisn't enough on its own — the cockatiel policy must also be configured to handle the sentinel withhandleType(BenzeneFailureResultException). Without that, the error the middleware throws isn't a handled outcome, so no strategy fires. - My circuit breaker never trips (or resets every message). You're building the policy per message. Construct the stateful policy once and reuse the instance across pipeline builds.
- A
timeoutpolicy fires but the handler keeps running. Expected — see the timeout caveat. The seam carries no cancellation token, sotimeoutbounds the wait, not the work. BrokenCircuitError/TaskCancelledErrorreaches my error handler. Those are cockatiel's real errors (breaker open, timeout elapsed) and propagate by design — only the internalBenzeneFailureResultExceptionsentinel is swallowed. Handle them with afallbackpolicy or your pipeline's exception handler.
See also
- Resilience — Benzene's own retry-with-backoff middleware and the full cockatiel reference this cookbook draws on.
- Handling SQS Message Failures — the transport whose redelivery pairs with a resilience policy on the inbound pipeline.
- Idempotency — de-duplicating the redeliveries a retry/redrive can produce.
- Middleware — pipeline ordering and how a middleware wraps everything after it.
- Clients — the outbound routing
useResiliencePipelinewraps in Step 4. - cockatiel documentation — the strategy catalogue.