Skip to content
Buy me a coffee

Calling rate-limited APIs

The problem. A CRM, payment or LLM API gives your API key a quota: 100 requests per 10 s, 10 000 tokens per minute. Go over it and you get 429 Too Many Requests, often with a Retry-After header. The naive setup makes this worse:

  • Retries burn the quota. Every caller that gets a 429 retries after 200 ms, the retries get 429s too, and the quota never recovers. Some APIs extend the penalty or suspend the key for that.
  • One tenant starves the others. A customer’s bulk import uses the whole quota; every other customer’s requests fail.
  • Retry-After is ignored, or obeyed blindly: a Retry-After: 3600 that the caller sleeps through, holding a request open for an hour.

The fix has four layers, and they work together:

  1. Limit on your side to the quota, so you rarely see a 429 at all.
  2. Honour Retry-After when you do, and block the local limiter for that long.
  3. Partition the limit per tenant, so one tenant can’t use everyone’s share.
  4. Cap retries with a time limit and a budget.

The fetch integration already does layer 2 for you: it retries 429 and 503, waits at least the Retry-After the response asks for (seconds or an HTTP date), and drains the discarded response. Add a time limit and a budget:

import {
class RetryBudget

Caps retries relative to traffic, so a failing dependency gets at most ratio extra load instead of a retry storm (every caller retrying 3x = 4x load exactly when the dependency is weakest). Share one budget across every pipeline that calls the same dependency: addRetry({ budget }).

RetryBudget
} from '@ylcnfrht/resilience';
import {
function createResilientFetch(options?: ResilientFetchOptions): ResilientFetch

A drop-in fetch with timeouts, retries and a circuit breaker per host: const http = createResilientFetch({ name: 'payments' }); const res = await http(url, { resilience: { retries: 1 } }); Responses are returned as-is (a final 503 resolves, like fetch); only network errors and resilience rejections throw.

createResilientFetch
} from '@ylcnfrht/resilience/fetch';
const
const crmBudget: RetryBudget
crmBudget
= new
new RetryBudget(options?: RetryBudgetOptions): RetryBudget

Caps retries relative to traffic, so a failing dependency gets at most ratio extra load instead of a retry storm (every caller retrying 3x = 4x load exactly when the dependency is weakest). Share one budget across every pipeline that calls the same dependency: addRetry({ budget }).

RetryBudget
({
RetryBudgetOptions.ratio?: number | undefined

Retries allowed as a fraction of original requests in the window. Default 0.2 (at most +20% load from retries).

ratio
: 0.1 }); // retries add at most 10% to the request rate
const
const crm: ResilientFetch
crm
=
function createResilientFetch(options?: ResilientFetchOptions): ResilientFetch

A drop-in fetch with timeouts, retries and a circuit breaker per host: const http = createResilientFetch({ name: 'payments' }); const res = await http(url, { resilience: { retries: 1 } }); Responses are returned as-is (a final 503 resolves, like fetch); only network errors and resilience rejections throw.

createResilientFetch
({
HttpResilienceOptions<Response>.name?: string | undefined

Labels telemetry. Per-host pipelines are named <name>/<key>.

name
: 'crm',
HttpResilienceOptions<Response>.totalTimeout?: Duration | undefined

Budget for the whole call, retries included. Default 30 s.

totalTimeout
: '30s',
HttpResilienceOptions<Response>.retry?: false | Omit<RetryOptions<Response>, "shouldHandle"> | undefined

Retry options merged over the defaults (3 retries, exponential backoff from 200 ms, full jitter, Retry-After honoured). false disables retries.

retry
: {
maxRetryAttempts?: number | undefined

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

maxRetryAttempts
: 4,
maxRetryTime?: Duration | undefined

Stop scheduling retries once this much time has passed since the first attempt (the attempt in flight isn't cancelled).

maxRetryTime
: '20s', // a Retry-After that ends past this: return the 429 now instead of waiting
budget?: RetryBudget | undefined

Shared cap on retries relative to traffic (retry-storm protection). Share one instance per dependency.

budget
:
const crmBudget: RetryBudget
crmBudget
,
},
});
const
const res: Response
res
= await
const crm: (input: string | URL | Request, init?: ResilientRequestInit) => Promise<Response> (+1 overload)
crm
('https://api.crm.example/contacts?page=2');
if (
const res: Response
res
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
=== 429) {
// Retries exhausted, over the budget, or Retry-After too long: the last response is returned as is.
}

Why maxRetryTime: the Retry-After delay is used as is (it is not capped by maxDelay). Without a limit, a Retry-After: 120 makes the call sleep until totalTimeout aborts it with a TimeoutRejectedError. With maxRetryTime, a retry that would start too late isn’t scheduled, and you get the 429 response back right away, which you can pass on to your own caller with its Retry-After.

For an SDK or your own HTTP client, put the pieces together yourself:

crm.ts
import {
class RateLimiterControl

Manual control of the rate limiters it is passed to (control option): penalty, reward, block, unblock, delete. key picks a partition of a partitioned limiter (omitted: every partition); single limiters ignore it. Pass it to the partitioned limiter, not to the per-key limiters it creates.

RateLimiterControl
,
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
,
class RetryBudget

Caps retries relative to traffic, so a failing dependency gets at most ratio extra load instead of a retry storm (every caller retrying 3x = 4x load exactly when the dependency is weakest). Share one budget across every pipeline that calls the same dependency: addRetry({ budget }).

RetryBudget
} from '@ylcnfrht/resilience';
import {
function parseRetryAfter(value: string | null | undefined, now?: number): number | undefined

Delay from a Retry-After header value (seconds or HTTP date), in ms; undefined when absent or unparseable.

parseRetryAfter
} from '@ylcnfrht/resilience/fetch';
/** What the client throws for an error response. `retryAfter` is in ms. */
class
class HttpError

What the client throws for an error response. retryAfter is in ms.

HttpError
extends
var Error: ErrorConstructor
Error
{
readonly
HttpError.status: number
status
: number;
readonly
HttpError.retryAfter: number | undefined
retryAfter
: number | undefined;
constructor(
status: number
status
: number,
retryAfter: number | undefined
retryAfter
?: number) {
super(`HTTP ${
status: number
status
}`);
this.
HttpError.status: number
status
=
status: number
status
;
this.
HttpError.retryAfter: number | undefined
retryAfter
=
retryAfter: number | undefined
retryAfter
;
}
}
const
const quota: RateLimiterControl
quota
= new
new RateLimiterControl(): RateLimiterControl

Manual control of the rate limiters it is passed to (control option): penalty, reward, block, unblock, delete. key picks a partition of a partitioned limiter (omitted: every partition); single limiters ignore it. Pass it to the partitioned limiter, not to the per-key limiters it creates.

RateLimiterControl
();
export const
const crm: ResiliencePipeline<any, WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>
crm
= 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
: 'crm' })
.
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
: 4,
RetryOptions<any>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '1s',
RetryOptions<any>.backoffType?: BackoffType | undefined

Default 'constant'.

backoffType
: 'exponential',
RetryOptions<any>.useJitter?: Jitter | undefined
useJitter
: 'full',
RetryOptions<any>.maxRetryTime?: Duration | undefined

Stop scheduling retries once this much time has passed since the first attempt (the attempt in flight isn't cancelled).

maxRetryTime
: '20s',
RetryOptions<any>.budget?: RetryBudget | undefined

Shared cap on retries relative to traffic (retry-storm protection). Share one instance per dependency.

budget
: new
new RetryBudget(options?: RetryBudgetOptions): RetryBudget

Caps retries relative to traffic, so a failing dependency gets at most ratio extra load instead of a retry storm (every caller retrying 3x = 4x load exactly when the dependency is weakest). Share one budget across every pipeline that calls the same dependency: addRetry({ budget }).

RetryBudget
({
RetryBudgetOptions.ratio?: number | undefined

Retries allowed as a fraction of original requests in the window. Default 0.2 (at most +20% load from retries).

ratio
: 0.1 }),
})
// The quota is 100 per 10 s. Wait up to 5 s for a permit rather than failing at once.
.
ResiliencePipelineBuilder<any, never, never>.addSlidingWindowRateLimiter(options: SlidingWindowRateLimiterOptions): Next<any, never, never, RateLimiterRejectedError>

Sliding-window rate limit: smooths the burst a fixed window allows at its edges.

addSlidingWindowRateLimiter
({
FixedWindowRateLimiterOptions.permitLimit: number

Permits granted per window.

permitLimit
: 100,
FixedWindowRateLimiterOptions.window: Duration
window
: '10s',
PermitRateLimiterOptions.queueTimeout?: Duration | undefined

Longest an execution may wait for permits before it is rejected. Waiters are served first come, first served. Default 0: reject right away.

queueTimeout
: '5s',
PermitRateLimiterOptions.queueLimit?: number | undefined

Most executions waiting (with queueTimeout); beyond it they are rejected at once. Default: no limit, as queueTimeout (default 0) already decides whether anyone waits.

queueLimit
: 500,
PermitRateLimiterOptions.control?: RateLimiterControl | undefined
control
:
const quota: RateLimiterControl
quota
})
.
ResiliencePipelineBuilder<any, RateLimiterRejectedError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, RateLimiterRejectedError, never, TimeoutRejectedError>

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

addTimeout
('10s')
.
ResiliencePipelineBuilder<any, WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>
build
();
export async function
function crmGet(path: string, signal: AbortSignal): Promise<unknown>
crmGet
(
path: string
path
: string,
signal: AbortSignal
signal
:
interface AbortSignal

The AbortSignal interface represents a signal object that allows you to communicate with an asynchronous operation (such as a fetch request) and abort it if required via an AbortController object.

MDN Reference

AbortSignal
):
interface Promise<T>

Represents the completion of an asynchronous operation

Promise
<unknown> {
const
const res: Response
res
= await
function fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> (+1 overload)
fetch
(`https://api.crm.example${
path: string
path
}`, {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
});
if (!
const res: Response
res
.
Response.ok: boolean

The ok read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.

MDN Reference

ok
) {
const
const retryAfter: number | undefined
retryAfter
=
function parseRetryAfter(value: string | null | undefined, now?: number): number | undefined

Delay from a Retry-After header value (seconds or HTTP date), in ms; undefined when absent or unparseable.

parseRetryAfter
(
const res: Response
res
.
Response.headers: Headers

The headers read-only property of the with the response.

MDN Reference

headers
.
Headers.get(name: string): string | null

The get() method of the Headers interface returns a byte string of all the values of a header within a Headers object with a given name.

MDN Reference

get
('retry-after'));
// The server knows the quota better than we do: hold every other call back too.
if (
const res: Response
res
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
=== 429 &&
const retryAfter: number | undefined
retryAfter
!==
var undefined
undefined
)
const quota: RateLimiterControl
quota
.
RateLimiterControl.block(duration: Duration, key?: string): void

Reject everything (for key) for duration.

block
(
const retryAfter: number
retryAfter
);
await
const res: Response
res
.
Body.body: ReadableStream<Uint8Array<ArrayBuffer>> | null
body
?.
ReadableStream<Uint8Array<ArrayBuffer>>.cancel(reason?: any): Promise<void>

The cancel() method of the ReadableStream interface returns a Promise that resolves when the stream is canceled.

MDN Reference

cancel
();
throw new
constructor HttpError(status: number, retryAfter?: number): HttpError

What the client throws for an error response. retryAfter is in ms.

HttpError
(
const res: Response
res
.
Response.status: number

The status read-only property of the Response interface contains the HTTP status codes of the response.

MDN Reference

status
,
const retryAfter: number | undefined
retryAfter
);
}
return
const res: Response
res
.
Body.json(): Promise<any>
json
();
}
const
const contacts: unknown
contacts
= await
const crm: ResiliencePipeline<any, WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>
crm
.
ResiliencePipeline<any, WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>.execute<unknown>(callback: (context: ResilienceContext) => unknown, options?: ExecuteOptions): Promise<unknown>

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 crmGet(path: string, signal: AbortSignal): Promise<unknown>
crmGet
('/contacts',
signal: AbortSignal

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

signal
));

How the layers interact:

  • The local limiter comes after the retry, so every retry takes a permit too. A retry can’t jump the quota.
  • retryAfter on the error is read by the retry strategy (respectRetryAfter, on by default): the delay before the next attempt is at least that long. Any handled error with a numeric retryAfter in ms works.
  • quota.block(retryAfter) makes the local limiter treat the quota as used up until the server’s reset. Calls queue for it (up to queueTimeout) instead of each one making its own trip to a 429.
  • Local rejections are not retried. When the limiter can’t grant a permit within queueTimeout, the call fails with RateLimiterRejectedError (code: 'RATE_LIMITED', with retryAfter). The default retry predicate never retries rejections: waiting is the limiter’s job (queueTimeout), and retrying would only queue the call again.
  • 4xx other than 408 and 429 aren’t retried. The default predicate reads status from the error.

LLM and GraphQL APIs often meter tokens or points per call. Give each call a weight with permits:

const
const COST: "cost"
COST
= 'cost';
const
const llm: ResiliencePipeline<string, RateLimiterRejectedError>
llm
= new
new ResiliencePipelineBuilder<string, never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<string, 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
<string>({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

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

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

maxRetryAttempts
: 3,
RetryOptions<string>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '2s',
RetryOptions<string>.backoffType?: BackoffType | undefined

Default 'constant'.

backoffType
: 'exponential',
RetryOptions<string>.useJitter?: Jitter | undefined
useJitter
: true })
// 90 000 tokens per minute; a call is charged its prompt + max output estimate.
.
ResiliencePipelineBuilder<string, never, never>.addSlidingWindowRateLimiter(options: SlidingWindowRateLimiterOptions): Next<string, never, never, RateLimiterRejectedError>

Sliding-window rate limit: smooths the burst a fixed window allows at its edges.

addSlidingWindowRateLimiter
({
FixedWindowRateLimiterOptions.permitLimit: number

Permits granted per window.

permitLimit
: 90_000,
FixedWindowRateLimiterOptions.window: Duration
window
: '1m',
PermitRateLimiterOptions.queueTimeout?: Duration | undefined

Longest an execution may wait for permits before it is rejected. Waiters are served first come, first served. Default 0: reject right away.

queueTimeout
: '10s',
PermitRateLimiterOptions.permits?: ((context: ResilienceContext) => number) | undefined

Weight of an execution (e.g. batch size, cost). Default 1.

permits
: (
ctx: ResilienceContext
ctx
) =>
ctx: ResilienceContext
ctx
.
ResilienceContext.properties: Map<string, unknown>

Shared bag for passing data between strategies and the callback.

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 COST: "cost"
COST
) as number })
.
ResiliencePipelineBuilder<string, RateLimiterRejectedError, never>.build(): ResiliencePipeline<string, RateLimiterRejectedError>
build
();
const
const prompt: "Classify this ticket: …"
prompt
= 'Classify this ticket: …';
const
const maxTokens: 500
maxTokens
= 500;
const
const label: string
label
= await
const llm: ResiliencePipeline<string, RateLimiterRejectedError>
llm
.
ResiliencePipeline<string, RateLimiterRejectedError>.execute<string>(callback: (context: ResilienceContext) => MaybePromise<string>, options?: ExecuteOptions): Promise<string>

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 complete(prompt: string, maxTokens: number, signal: AbortSignal): Promise<string>
complete
(
const prompt: "Classify this ticket: …"
prompt
,
const maxTokens: 500
maxTokens
,
signal: AbortSignal

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

signal
), {
ExecuteOptions.properties?: Map<string, unknown> | undefined
properties
: new
var Map: MapConstructor
new <string, number>(iterable?: Iterable<readonly [string, number]> | null | undefined) => Map<string, number> (+3 overloads)
Map
([[
const COST: "cost"
COST
,
var Math: Math

An intrinsic object that provides basic mathematics functionality and constants.

Math
.
Math.ceil(x: number): number

Returns the smallest integer greater than or equal to its numeric argument.

@param ― x A numeric expression.

ceil
(
const prompt: "Classify this ticket: …"
prompt
.
String.length: number

Returns the length of a String object.

length
/ 4) +
const maxTokens: 500
maxTokens
]]),
});

A weight above permitLimit can never fit and fails at once with PermitLimitExceededError. Waiters are served first come, first served, so one big call isn’t overtaken forever by small ones.

When your own customers trigger the calls, a single shared limit lets one of them use it all. Partition it: one limiter per tenant, created on first use, with the API’s overall limit behind it.

const
const TENANT: "tenant"
TENANT
= 'tenant';
const
const searchApi: ResiliencePipeline<unknown[], WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>
searchApi
= new
new ResiliencePipelineBuilder<unknown[], never, never>(options?: ResiliencePipelineBuilderOptions): ResiliencePipelineBuilder<unknown[], 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
<unknown[]>({
ResiliencePipelineBuilderOptions.name?: string | undefined

Labels telemetry events.

name
: 'search-api' })
// Each tenant: bursts of 20, then 5 per second.
.
ResiliencePipelineBuilder<unknown[], never, never>.addPartitionedRateLimiter(options: PartitionedRateLimiterOptions<unknown[]>): Next<unknown[], never, never, RateLimiterRejectedError>

One limiter per key (user, tenant, API key…), created on demand and evicted when idle.

addPartitionedRateLimiter
({
PartitionedRateLimiterOptions<unknown[]>.partitionKey: (context: ResilienceContext) => string

Which partition an execution counts against, e.g. (ctx) => ctx.properties.get('userId') as string.

partitionKey
: (
ctx: ResilienceContext
ctx
) =>
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

String
(
ctx: ResilienceContext
ctx
.
ResilienceContext.properties: Map<string, unknown>

Shared bag for passing data between strategies and the callback.

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 TENANT: "tenant"
TENANT
)),
PartitionedRateLimiterOptions<unknown[]>.limiter: (key: string, source: StrategySource) => ResilienceStrategy<unknown[]>

Creates the limiter of a new partition, e.g. (key, s) => new SlidingWindowRateLimiterStrategy({ permitLimit: 100, window: '1m' }, s).

limiter
: (
_tenant: string
_tenant
,
s: StrategySource
s
) => new
new TokenBucketStrategy<unknown[]>(options: TokenBucketOptions, source: StrategySource): TokenBucketStrategy<unknown[]>

Token bucket rate limiter, e.g. for an external API quota.

TokenBucketStrategy
({
TokenBucketOptions.tokenLimit: number

Bucket size (largest burst), and the number of tokens it starts with.

tokenLimit
: 20,
TokenBucketOptions.replenishmentPeriod: Duration

Refill interval.

replenishmentPeriod
: '1s',
TokenBucketOptions.tokensPerPeriod?: number | undefined

Tokens added per period. Default tokenLimit.

tokensPerPeriod
: 5,
PermitRateLimiterOptions.queueTimeout?: Duration | undefined

Longest an execution may wait for permits before it is rejected. Waiters are served first come, first served. Default 0: reject right away.

queueTimeout
: '1s' },
s: StrategySource
s
),
})
// The whole API key: 1 000 per minute.
.
ResiliencePipelineBuilder<unknown[], RateLimiterRejectedError, never>.addSlidingWindowRateLimiter(options: SlidingWindowRateLimiterOptions): Next<unknown[], RateLimiterRejectedError, never, RateLimiterRejectedError>

Sliding-window rate limit: smooths the burst a fixed window allows at its edges.

addSlidingWindowRateLimiter
({
FixedWindowRateLimiterOptions.permitLimit: number

Permits granted per window.

permitLimit
: 1_000,
FixedWindowRateLimiterOptions.window: Duration
window
: '1m',
PermitRateLimiterOptions.queueTimeout?: Duration | undefined

Longest an execution may wait for permits before it is rejected. Waiters are served first come, first served. Default 0: reject right away.

queueTimeout
: '5s' })
.
ResiliencePipelineBuilder<unknown[], RateLimiterRejectedError, never>.addTimeout(options: Duration | TimeoutOptions): Next<unknown[], RateLimiterRejectedError, never, TimeoutRejectedError>

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

addTimeout
('5s')
.
ResiliencePipelineBuilder<unknown[], WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<unknown[], WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>
build
();
const
const hits: unknown[]
hits
= await
const searchApi: ResiliencePipeline<unknown[], WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>
searchApi
.
ResiliencePipeline<unknown[], WithErrors<RateLimiterRejectedError, never, TimeoutRejectedError>>.execute<unknown[]>(callback: (context: ResilienceContext) => MaybePromise<unknown[]>, options?: ExecuteOptions): Promise<unknown[]>

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 search(query: string, signal: AbortSignal): Promise<unknown[]>
search
('red shoes',
signal: AbortSignal

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

signal
), {
ExecuteOptions.properties?: Map<string, unknown> | undefined
properties
: new
var Map: MapConstructor
new <string, string>(iterable?: Iterable<readonly [string, string]> | null | undefined) => Map<string, string> (+3 overloads)
Map
([[
const TENANT: "tenant"
TENANT
,
const tenantId: string
tenantId
]]),
});

The per-tenant limiter comes first, so a tenant over its share is rejected before it takes a permit from the shared quota. Size the shares so that their sum can exceed the overall limit (not every tenant is busy at once), but no single tenant can reach it.

Partitions unused for idleTimeout (default 10 minutes) are dropped, which resets their count; keep it longer than the limiter’s window. With several instances, use redisPartitionLimiter so the shares hold across the fleet (see running several instances).

maxRetryAttempts: 3 means “up to 4× the load” when the API is struggling, exactly when it can least take it. A RetryBudget caps retries relative to traffic: with ratio: 0.1, retries add at most 10% to the requests sent in the last window (default 10 s), plus minRetriesPerSecond (default 10) so a quiet service can still retry.

// One budget per dependency, shared by every pipeline that calls it.
const
const crmBudget: RetryBudget
crmBudget
= new
new RetryBudget(options?: RetryBudgetOptions): RetryBudget

Caps retries relative to traffic, so a failing dependency gets at most ratio extra load instead of a retry storm (every caller retrying 3x = 4x load exactly when the dependency is weakest). Share one budget across every pipeline that calls the same dependency: addRetry({ budget }).

RetryBudget
({
RetryBudgetOptions.ratio?: number | undefined

Retries allowed as a fraction of original requests in the window. Default 0.2 (at most +20% load from retries).

ratio
: 0.1,
RetryBudgetOptions.minRetriesPerSecond?: number | undefined

Retries always allowed per second, so low-traffic callers can still retry. Default 10.

minRetriesPerSecond
: 2 });
const
const reads: ResiliencePipeline<any, never>
reads
= 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
: 'crm-reads' }).
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.budget?: RetryBudget | undefined

Shared cap on retries relative to traffic (retry-storm protection). Share one instance per dependency.

budget
:
const crmBudget: RetryBudget
crmBudget
,
RetryOptions<any>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '500ms' }).
ResiliencePipelineBuilder<any, never, never>.build(): ResiliencePipeline<any, never>
build
();
const
const writes: ResiliencePipeline<any, never>
writes
= 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
: 'crm-writes' }).
ResiliencePipelineBuilder<any, never, never>.addRetry(options?: RetryOptions<any>): ResiliencePipelineBuilder<any, never, never>
addRetry
({
RetryOptions<any>.budget?: RetryBudget | undefined

Shared cap on retries relative to traffic (retry-storm protection). Share one instance per dependency.

budget
:
const crmBudget: RetryBudget
crmBudget
,
RetryOptions<any>.delay?: Duration | undefined

Base delay. Default 2000 ms.

delay
: '1s' }).
ResiliencePipelineBuilder<any, never, never>.build(): ResiliencePipeline<any, never>
build
();
const crmBudget: RetryBudget
crmBudget
.
RetryBudget.stats(): {
requests: number;
retries: number;
}

Requests and retries counted in the current window.

stats
(); // { requests, retries } in the current window

When the budget refuses a retry, the last outcome is returned (the 429 error) and OnRetryBudgetExhausted is reported. The budget is per process; that’s fine, since the ratio holds for the fleet if it holds for each instance.

  • Local limiter matching the provider’s algorithm, with headroom, queueTimeout and a queueLimit.
  • Retry outside the limiter; retryAfter on your errors; maxRetryTime so a long Retry-After fails fast.
  • RateLimiterControl.block() on a 429 with Retry-After.
  • Weights for cost-metered APIs; partitions per tenant; Redis-backed limits when you run several instances.
  • One RetryBudget per dependency.