Skip to content

Express and Hono

The outbound integrations protect you from your dependencies. The inbound middleware protects your service from its callers. One middleware gives every request:

  • a deadline that propagates to every pipeline called while handling it, even ones that never receive a signal;
  • load shedding: a cap on requests in flight, with a short queue, and a fast 503 beyond it;
  • a rate limit, global or per client, with RateLimit-* headers and 429 + Retry-After beyond it;
  • cancellation when the client disconnects;
  • JSON error responses with the right status for every resilience error raised in your handlers.

Both live in resilience-pipeline/http and import no framework. Fastify and Koa have their own pages.

import express from 'express';
import { getResilienceSignal } from 'resilience-pipeline';
import { resilienceMiddleware } from 'resilience-pipeline/http';
const app = express();
app.use(
resilienceMiddleware({
timeout: '5s',
concurrency: { permitLimit: 200, queueLimit: 100, queueTimeout: '1s' },
rateLimit: { key: (req) => req.ip, permitLimit: 100, window: '1m' },
}),
);
app.get('/orders/:id', async (req, res, next) => {
try {
// Aborted when the 5 s budget runs out or the client goes away.
const order = await db.orders.find(req.params.id, { signal: getResilienceSignal() });
res.json(order);
} catch (error) {
next(error);
}
});

Register the middleware first, before routers and body parsers, so the deadline and the limits cover all the work.

rate limit (if set) → concurrency limit (if set) → timeout (if set) → your handlers

The rate limit is checked first, so a client over its quota never takes a concurrency slot. The timeout starts once the request has a slot: time spent in the queue is bounded by queueTimeout, not by timeout.

Option Type Default Description
timeout Duration none Budget per request. Everything called through a pipeline while handling it inherits the deadline.
concurrency.permitLimit number required Max requests in flight.
concurrency.queueLimit number 0 Requests that may wait for a slot. Beyond it: 503.
concurrency.queueTimeout Duration no limit Longest a request waits in the queue before a 503.
rateLimit.permitLimit number required Requests allowed per window.
rateLimit.window Duration required Window length, e.g. '1m'.
rateLimit.key (req) => string none (one limit for all) Partition key. Receives Express’s req or Hono’s c.
rateLimit.segmentsPerWindow number 10 Segments the sliding window moves by. 1 is a fixed window.
rateLimit.maxPartitions number 100000 Most keys tracked; the least recently used idle key is dropped beyond it.
rateLimitHeaders 'draft' | 'legacy' | 'both' | false 'draft' RateLimit-Limit/-Remaining/-Reset, X-RateLimit-*, both, or none. Reset is in seconds from now.
pipeline ResiliencePipeline built from the options above Your own pipeline; the other options are then ignored.

Rate-limit headers are only sent when the pipeline has a rate limiter.

  • Deadline propagation. Your handlers run inside the middleware’s execution (through AsyncLocalStorage). Any pipeline executed underneath, such as a resilient fetch, a database pipeline or a NestJS service, links its signal to the request’s. When the budget runs out they are all aborted. getResilienceSignal() returns the request’s signal anywhere in the call.
  • Timeout response. When timeout passes and nothing was sent yet, the client gets 504. Your handler keeps running until its own awaits notice the aborted signal, so check res.headersSent before writing late.
  • Client disconnect. When the connection closes before the response finished, the signal aborts with Client disconnected and nothing is sent.
  • When the slot is freed. Express holds the concurrency slot until the response finishes or the connection closes. Hono holds it until the handler chain returns.
  • Other errors. Errors that aren’t resilience errors are left to the framework: Express gets them through next(error) as usual, Hono’s middleware rethrows them.

The response body is always JSON:

{ "statusCode": 429, "error": "RateLimiterRejectedError", "message": "The operation could not be executed due to rate limiting. Retry after 2400ms." }

with a Retry-After header (seconds, rounded up) when the error knows when to retry: rate limits and open circuits.

The middleware only answers for its own rejections. A resilience error raised inside your handler (an open circuit or a timeout of a downstream call) goes to the framework’s error handling like any other error. One exception: when the request’s own timeout fires, nested pipelines rethrow that same TimeoutRejectedError, and Hono (like Fastify and Koa) still answers 504 for it. In Express the handler runs outside the middleware, so that error reaches your error handler; mapping it as below gives the same 504. Map it there:

import { describeResilienceError } from 'resilience-pipeline/http';
// After your routes.
app.use((error, req, res, next) => {
const d = describeResilienceError(error);
if (!d || res.headersSent) return next(error);
if (d.retryAfter !== undefined) res.set('retry-after', String(d.retryAfter));
res.status(d.httpStatus).json({ code: d.code, message: d.message });
});

describeResilienceError(error) and httpStatusFor(error) map an error by its code, not by instanceof, so they work across duplicate copies of the package.

code Error HTTP Why
TIMEOUT TimeoutRejectedError 504 The work didn’t finish in its budget.
RATE_LIMITED RateLimiterRejectedError 429 The caller is over its rate.
PERMIT_LIMIT_EXCEEDED PermitLimitExceededError 429 One execution asked for more permits than the limit holds.
RATE_LIMIT_DENIED RateLimiterDeniedError 403 Refused by the limiter’s deny rule.
BULKHEAD_FULL BulkheadRejectedError 503 Our capacity is full, not the client’s fault.
CIRCUIT_OPEN BrokenCircuitError 503 A dependency is failing.
CIRCUIT_ISOLATED IsolatedCircuitError 503 A circuit is held open by hand.
LOCK_NOT_ACQUIRED LockNotAcquiredError 409 Someone else holds the lock.
any other resilience error 503
not a resilience error undefined Leave it to your error handling.

Full list: error codes.

describeResilienceError() returns { code, httpStatus, retryAfter, message, source } (retryAfter in seconds) and doesn’t depend on HTTP. Use it wherever you build an error for a client:

// Apollo Server / GraphQL Yoga: formatError, or a resolver wrapper.
export function
function toGraphQLError(error: unknown): unknown
toGraphQLError
(
error: unknown
error
: unknown): unknown {
const
const d: ResilienceErrorDescription | undefined
d
=
function describeResilienceError(error: unknown): ResilienceErrorDescription | undefined

Transport-neutral description of a resilience error, or undefined for any other error. Use it to build an HTTP response, GraphQL extensions: { code, httpStatus, retryAfter }, a gRPC status, or a WebSocket error frame. Works across duplicate copies of this package (uses error codes, not instanceof).

describeResilienceError
(
error: unknown
error
);
if (!
const d: ResilienceErrorDescription | undefined
d
) return
error: unknown
error
;
return new
constructor GraphQLError(message: string, options?: {
extensions?: Record<string, unknown>;
}): GraphQLError
GraphQLError
(
const d: ResilienceErrorDescription
d
.
ResilienceErrorDescription.message: string
message
, {
extensions?: Record<string, unknown> | undefined
extensions
: {
code: string
code
:
const d: ResilienceErrorDescription
d
.
ResilienceErrorDescription.code: string

Stable machine-readable code, e.g. 'TIMEOUT', 'CIRCUIT_OPEN', 'BULKHEAD_FULL', 'RATE_LIMITED'.

code
,
http: {
status: number;
}
http
: {
status: number
status
:
const d: ResilienceErrorDescription
d
.
ResilienceErrorDescription.httpStatus: number
httpStatus
},
retryAfter: number | undefined
retryAfter
:
const d: ResilienceErrorDescription
d
.
ResilienceErrorDescription.retryAfter: number | undefined

Seconds, when known (for a Retry-After header or a GraphQL/RPC error payload).

retryAfter
},
});
}

For gRPC, map the code to a status:

code gRPC status
TIMEOUT DEADLINE_EXCEEDED (4)
RATE_LIMITED, PERMIT_LIMIT_EXCEEDED RESOURCE_EXHAUSTED (8)
RATE_LIMIT_DENIED PERMISSION_DENIED (7)
BULKHEAD_FULL, CIRCUIT_OPEN, CIRCUIT_ISOLATED UNAVAILABLE (14)
LOCK_NOT_ACQUIRED ABORTED (10)

retryAfterSeconds(error) is a shortcut for describeResilienceError(error)?.retryAfter.

Pass pipeline to use any strategies: queue priorities, the adaptive concurrency limiter instead of a fixed limit, a Redis-backed rate limit. The inbound request is in context.properties under HTTP_REQUEST, for partition keys and priorities:

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 'resilience-pipeline';
import {
const HTTP_REQUEST: "resilience-pipeline.httpRequest"

context.properties key of the inbound request (Express req / Hono c), for your own partitioned limiters.

HTTP_REQUEST
} from 'resilience-pipeline/http';
type
type Req = {
headers: Record<string, string | undefined>;
}
Req
= {
headers: Record<string, string | undefined>
headers
:
type Record<K extends keyof any, T> = { [P in K]: T; }

Construct a type with a set of properties K of type T

Record
<string, string | undefined> };
const
const plan: (ctx: {
properties: Map<string, unknown>;
}) => string | undefined
plan
= (
ctx: {
properties: Map<string, unknown>;
}
ctx
: {
properties: Map<string, unknown>
properties
:
interface Map<K, V>
Map
<string, unknown> }) => (
ctx: {
properties: Map<string, unknown>;
}
ctx
.
properties: Map<string, unknown>
properties
.
Map<string, unknown>.get(key: string): unknown

Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map.

@returns ― Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned.

get
(
const HTTP_REQUEST: "resilience-pipeline.httpRequest"

context.properties key of the inbound request (Express req / Hono c), for your own partitioned limiters.

HTTP_REQUEST
) as
type Req = {
headers: Record<string, string | undefined>;
}
Req
).
headers: Record<string, string | undefined>
headers
['x-plan'];
const
const inbound: ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
inbound
= 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
: 'inbound' })
.
ResiliencePipelineBuilder<any, never, never>.addConcurrencyLimiter(options: number | ConcurrencyLimiterOptions, queueLimit?: number): Next<any, never, never, BulkheadRejectedError>

addConcurrencyLimiter(10), addConcurrencyLimiter(10, 50) or full options.

addConcurrencyLimiter
({
ConcurrencyLimiterOptions.permitLimit: number

Max executions running at once.

permitLimit
: 100,
ConcurrencyLimiterOptions.queueLimit?: number | undefined

Max executions waiting (FIFO) for a permit. Default 0.

queueLimit
: 200,
ConcurrencyLimiterOptions.queueTimeout?: Duration | undefined

Longest a queued execution waits for a permit before it is rejected. Default: no limit.

queueTimeout
: '500ms',
ConcurrencyLimiterOptions.priority?: ((context: ResilienceContext) => number) | undefined

Queue order: lower runs sooner (like a Unix nice value); FIFO within a priority. Default 0 for everyone.

priority
: (
ctx: ResilienceContext
ctx
) => (
const plan: (ctx: {
properties: Map<string, unknown>;
}) => string | undefined
plan
(
ctx: ResilienceContext
ctx
) === 'paid' ? 0 : 10), // paid requests jump the queue
})
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, BulkheadRejectedError, never, TimeoutRejectedError>

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

addTimeout
('5s')
.
ResiliencePipelineBuilder<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
build
();
// app.use(resilienceMiddleware({ pipeline: inbound }))