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:
Limit on your side to the quota, so you rarely see a 429 at all.
Honour Retry-After when you do, and block the local limiter for that long.
Partition the limit per tenant, so one tenant can’t use everyone’s share.
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 {
classRetryBudget
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 }).
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.
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
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.
// 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 {
classRateLimiterControl
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.
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,
classRetryBudget
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 }).
/** What the client throws for an error response. `retryAfter` is in ms. */
class
classHttpError
What the client throws for an error response. retryAfter is in ms.
HttpErrorextends
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
constquota:RateLimiterControl
quota=new
newRateLimiterControl(): 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.
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().
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.
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.
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.
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.
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.
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().
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.
new <string, number>(iterable?:Iterable<readonly [string, number]> |null|undefined) =>Map<string, number> (+3 overloads)
Map([[
constCOST:"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(
constprompt:"Classify this ticket: …"
prompt.
String.length: number
Returns the length of a String object.
length/4) +
constmaxTokens: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.
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().
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.
new <string, string>(iterable?:Iterable<readonly [string, string]> |null|undefined) =>Map<string, string> (+3 overloads)
Map([[
constTENANT:"tenant"
TENANT,
consttenantId: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.
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.
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().
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().
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.