Skip to content
Buy me a coffee

Adaptive concurrency

A fixed concurrency limit needs a number, and every number is wrong somewhere: too low wastes capacity on a good day, too high lets requests pile up in the dependency’s queue on a bad one. The adaptive limiter finds the number by watching the dependency. While latency stays flat, it raises the limit. When latency climbs or calls time out, the dependency is queueing, and the limit drops. This is how TCP congestion control (and Netflix’s concurrency-limits) works. Use it in front of a dependency whose capacity changes (autoscaling, shared databases, noisy neighbours). Don’t use it when you need a hard, contractual cap, such as a connection pool size: use a fixed limiter for that.

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 '@nodelibraries/resilience';
const
const pipeline: ResiliencePipeline<any, WithErrors<BulkheadRejectedError, 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
()
.
ResiliencePipelineBuilder<any, never, never>.addAdaptiveConcurrencyLimiter(options?: AdaptiveConcurrencyLimiterOptions<any>): Next<any, never, never, BulkheadRejectedError>

Concurrency limit that tunes itself from observed latency and failures (gradient2 / vegas / aimd), so you don't have to guess the right number: it grows while the dependency keeps up and backs off when it queues.

addAdaptiveConcurrencyLimiter
({
AdaptiveConcurrencyLimiterOptions<any>.minLimit?: number | undefined

Default 1.

minLimit
: 5,
AdaptiveConcurrencyLimiterOptions<any>.maxLimit?: number | undefined

Default 1000.

maxLimit
: 200 })
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.addTimeout(options: Duration | TimeoutOptions): Next<any, BulkheadRejectedError, never, TimeoutRejectedError>

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

addTimeout
('2s')
.
ResiliencePipelineBuilder<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>, never>.build(): ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
build
();
await
const pipeline: ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>
pipeline
.
ResiliencePipeline<any, WithErrors<BulkheadRejectedError, never, TimeoutRejectedError>>.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://search.internal/q?term=resilience', {
RequestInit.signal?: AbortSignal | null | undefined

An AbortSignal to set request's signal.

signal
}));
0255075100dependency slowedlatency (ms)060120timeout0 s10 s20 s30 s
  • limit
  • in flight
  • dependency capacity
  • latency
  • drop (timeout)

gradient2: limit 53 before, 19 during and 59 after the slowdown; 5 drops.

Limit, 5–10 s53
Limit, 15–20 s19
Limit, 25–30 s59
Drops counted5

Simulated dependency: 40 ms per call while up to 30 run at once (10 while slowed); above that calls queue inside it and latency grows. Offered load is 1500 req/s, so the limit, not the traffic, decides how many run. Each new limit comes from the library's gradient2(), with the strategy's clamping, app-limited and one-cut-per-round-trip rules.

addAdaptiveConcurrencyLimiter({ algorithm: 'gradient2', initialLimit: 20, maxLimit: 100 }).addTimeout('120ms')

Admission is the same as the fixed limiter: run if inflight < limit, else wait in a FIFO queue (up to queueLimit), else reject with BulkheadRejectedError. The difference is what happens after each call:

  1. The strategy measures the call’s latency (rtt) and whether it was a drop, an outcome that signals overload (shouldDrop; by default timeouts, rejections and transient errors).
  2. It feeds that sample to the algorithm, which returns a new limit.
  3. The new limit is clamped to [minLimit, maxLimit]. If its whole-number value changed, OnLimitChanged fires and queued waiters are let in.

Three guards keep the limit honest:

  • Caller aborts are ignored. A client that hung up says nothing about the dependency. A timeout abort does count (the call was too slow).
  • One cut per round trip. Calls admitted before the last cut ran under the old, higher limit. Their drops are expected, so they don’t cut the limit again (as in TCP).
  • No growth while app-limited. If less than half the limit was in use, low latency proves nothing about higher concurrency, so the limit doesn’t grow.

The limit is fractional internally; limit reports whole permits.

Algorithm Signal Best for
'gradient2' (default) Short-term latency vs a long-term baseline General use. Handles latency that drifts slowly.
'vegas' Estimated queue size from the fastest latency seen Dependencies with a stable no-load latency. Converges fast.
'aimd' Drops only, ignores latency Dependencies with noisy latency. Needs a timeout to produce drops.

Pass a name for the defaults, or call the factory to tune it:

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
,
function vegas(options?: VegasOptions): LimitAlgorithm

TCP Vegas applied to concurrency. The fastest latency seen is the no-queue latency; with limit calls in flight at latency rtt, limit * (1 - minRtt / rtt) of them are waiting in the dependency's queue. Keep that queue between alpha and beta: below means unused capacity, above only adds latency. Thresholds scale with log10(limit) so large limits tolerate a longer queue. Instead of Vegas' fixed +-1 steps, the limit moves toward the band by the queue error (damped), which converges in a few round trips.

vegas
} from '@nodelibraries/resilience';
const
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
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>.addAdaptiveConcurrencyLimiter(options?: AdaptiveConcurrencyLimiterOptions<any>): Next<any, never, never, BulkheadRejectedError>

Concurrency limit that tunes itself from observed latency and failures (gradient2 / vegas / aimd), so you don't have to guess the right number: it grows while the dependency keeps up and backs off when it queues.

addAdaptiveConcurrencyLimiter
({
AdaptiveConcurrencyLimiterOptions<any>.algorithm?: LimitAlgorithm | "vegas" | "gradient2" | "aimd" | undefined

Default 'gradient2'. Pass an object for custom tuning (vegas({ alpha: 2 })) or your own algorithm.

algorithm
:
function vegas(options?: VegasOptions): LimitAlgorithm

TCP Vegas applied to concurrency. The fastest latency seen is the no-queue latency; with limit calls in flight at latency rtt, limit * (1 - minRtt / rtt) of them are waiting in the dependency's queue. Keep that queue between alpha and beta: below means unused capacity, above only adds latency. Thresholds scale with log10(limit) so large limits tolerate a longer queue. Instead of Vegas' fixed +-1 steps, the limit moves toward the band by the queue error (damped), which converges in a few round trips.

vegas
({
VegasOptions.alpha?: number | undefined

Grow while the estimated queue is below alpha * log10(limit). Default 3.

alpha
: 2,
VegasOptions.beta?: number | undefined

Shrink when the estimated queue is above beta * log10(limit). Default 6.

beta
: 4 }),
AdaptiveConcurrencyLimiterOptions<any>.initialLimit?: number | undefined

Default 20.

initialLimit
: 10 })
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.build(): ResiliencePipeline<any, BulkheadRejectedError>
build
();

gradient = tolerance × baseline / shortLatency, clamped to [0.5, 1]. At 1 (latency within tolerance), the limit grows by queueSize(limit); below 1, it shrinks in proportion. The new estimate is blended into the old one by smoothing. The baseline drops at once to any lower short-term latency, and rises slowly (over longWindow samples) only while latency is within tolerance. A plain long-term average would instead drift up to the overloaded latency under sustained overload, and the limit would creep up without bound. Samples with rtt of 0 (a coarse clock such as Date.now()) carry no latency signal and are ignored.

Option Type Default Description
tolerance number 1.5 How far short-term latency may exceed the baseline before the limit shrinks. ≥ 1.
shortWindow number 10 Samples in the short-term latency EMA.
longWindow number 600 Samples in the long-term baseline EMA.
smoothing number 0.2 Weight of each new estimate, in (0, 1]. Lower is steadier.
queueSize (limit) => number Math.sqrt Headroom added on top of limit × gradient, so the limit can grow.
backoffRatio number 0.9 Multiplier on a drop, in (0, 1).

TCP Vegas applied to concurrency. The fastest latency seen is the no-queue latency, so with limit calls in flight at latency rtt, about limit × (1 − minRtt / rtt) of them are queued inside the dependency. The limit moves to keep that queue between alpha × log10(limit) and beta × log10(limit): below means unused capacity, above only adds latency. A drop cuts the limit by beta × log10(limit).

Option Type Default Description
alpha number 3 Grow while the estimated queue is below alpha × log10(limit). > 0.
beta number 6 Shrink when the estimated queue is above beta × log10(limit). > alpha.

Additive increase, multiplicative decrease (TCP Reno): +increase / limit per successful sample (so about +increase per round trip), × backoffRatio per drop.

Option Type Default Description
increase number 1 Added per round trip. > 0.
backoffRatio number 0.9 Multiplier on a drop, in (0, 1).

Any object with onSample(sample) => number works. The sample is { rtt, inflight, dropped, limit }; return the next limit (the strategy clamps it, and ignores non-finite values).

import { type
(alias) interface LimitAlgorithm
import LimitAlgorithm

Computes the next concurrency limit from a sample. Stateful: give each strategy its own instance. The strategy clamps the result to [minLimit, maxLimit].

LimitAlgorithm
,
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 '@nodelibraries/resilience';
// Halve on overload, otherwise creep up by one per call.
const
const halving: LimitAlgorithm
halving
:
(alias) interface LimitAlgorithm
import LimitAlgorithm

Computes the next concurrency limit from a sample. Stateful: give each strategy its own instance. The strategy clamps the result to [minLimit, maxLimit].

LimitAlgorithm
= {
LimitAlgorithm.onSample(sample: LimitSample): number
onSample
: ({
dropped: boolean

The dependency showed overload (timeout, rejection, transient error).

dropped
,
limit: number

The current limit (may be fractional).

limit
}) => (
dropped: boolean

The dependency showed overload (timeout, rejection, transient error).

dropped
?
limit: number

The current limit (may be fractional).

limit
/ 2 :
limit: number

The current limit (may be fractional).

limit
+ 1),
};
const
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
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>.addAdaptiveConcurrencyLimiter(options?: AdaptiveConcurrencyLimiterOptions<any>): Next<any, never, never, BulkheadRejectedError>

Concurrency limit that tunes itself from observed latency and failures (gradient2 / vegas / aimd), so you don't have to guess the right number: it grows while the dependency keeps up and backs off when it queues.

addAdaptiveConcurrencyLimiter
({
AdaptiveConcurrencyLimiterOptions<any>.algorithm?: LimitAlgorithm | "vegas" | "gradient2" | "aimd" | undefined

Default 'gradient2'. Pass an object for custom tuning (vegas({ alpha: 2 })) or your own algorithm.

algorithm
:
const halving: LimitAlgorithm
halving
}).
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.build(): ResiliencePipeline<any, BulkheadRejectedError>
build
();
Option Type Default Description
algorithm 'gradient2' | 'vegas' | 'aimd' | LimitAlgorithm 'gradient2' How the limit moves.
initialLimit number 20 (clamped into [minLimit, maxLimit]) Starting limit. An explicit value must lie within [minLimit, maxLimit].
minLimit number 1 Floor. ≥ 1.
maxLimit number 1000 Ceiling. ≥ minLimit.
queueLimit number 0 Max executions waiting (FIFO) for a permit.
queueTimeout Duration no limit Longest a queued execution waits before it is rejected (reason: 'queue-timeout'). > 0.
shouldDrop (args: { outcome, context }) => boolean defaultShouldDrop Which outcomes signal overload. Default: timeouts, rejections and transient errors; not client errors (4xx) or aborts.
limitProvider AdaptiveLimitProvider – Exposes limit, inflight and queued outside the pipeline.
onLimitChanged (args: { oldLimit, newLimit, sample }) => void – Called when the whole-number limit changes.
onRejected (args: { context, retryAfter }) => void | Promise<void> – Called on every rejection. retryAfter is undefined.
name string 'AdaptiveConcurrencyLimiter' Strategy name.

AdaptiveLimitProvider reads the live state of one limiter, e.g. for a health endpoint or a gauge. A provider attaches to a single limiter; attaching it twice throws. The strategy instance (pipeline.getStrategy('AdaptiveConcurrencyLimiter')) has the same limit, inflight and queued getters.

import {
class AdaptiveLimitProvider

Reads the live limit of one adaptive limiter (health checks, dashboards).

AdaptiveLimitProvider
,
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 '@nodelibraries/resilience';
const
const provider: AdaptiveLimitProvider
provider
= new
new AdaptiveLimitProvider(): AdaptiveLimitProvider

Reads the live limit of one adaptive limiter (health checks, dashboards).

AdaptiveLimitProvider
();
const
const pipeline: ResiliencePipeline<any, BulkheadRejectedError>
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>.addAdaptiveConcurrencyLimiter(options?: AdaptiveConcurrencyLimiterOptions<any>): Next<any, never, never, BulkheadRejectedError>

Concurrency limit that tunes itself from observed latency and failures (gradient2 / vegas / aimd), so you don't have to guess the right number: it grows while the dependency keeps up and backs off when it queues.

addAdaptiveConcurrencyLimiter
({
AdaptiveConcurrencyLimiterOptions<any>.limitProvider?: AdaptiveLimitProvider | undefined

Read limit / inflight / queued from outside.

limitProvider
:
const provider: AdaptiveLimitProvider
provider
,
AdaptiveConcurrencyLimiterOptions<any>.onLimitChanged?: ((args: OnLimitChangedArgs) => void) | undefined
onLimitChanged
: ({
oldLimit: number
oldLimit
,
newLimit: number
newLimit
,
sample: LimitSample
sample
}) =>
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@see ― source

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@since ― v0.1.100

log
(`limit ${
oldLimit: number
oldLimit
} -> ${
newLimit: number
newLimit
} (rtt ${
sample: LimitSample
sample
.
LimitSample.rtt: number

Latency of the execution in ms.

rtt
} ms)`),
})
.
ResiliencePipelineBuilder<any, BulkheadRejectedError, never>.build(): ResiliencePipeline<any, BulkheadRejectedError>
build
();
function setInterval<[]>(callback: () => void, delay?: number): NodeJS.Timeout (+2 overloads)

Schedules repeated execution of callback every delay milliseconds.

When delay is larger than 2147483647 or less than 1 or NaN, the delay will be set to 1. Non-integer delays are truncated to an integer.

If callback is not a function, a TypeError will be thrown.

This method has a custom variant for promises that is available using timersPromises.setInterval().

@since ― v0.0.1

@param ― callback The function to call when the timer elapses.

@param ― delay The number of milliseconds to wait before calling the callback. Default: 1.

@param ― args Optional arguments to pass when the callback is called.

@returns ― for use with clearInterval()

setInterval
(() =>
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
// Error: Whoops, something bad happened
// at [eval]:5:15
// at Script.runInThisContext (node:vm:132:18)
// at Object.runInThisContext (node:vm:309:38)
// at node:internal/process/execution:77:19
// at [eval]-wrapper:6:22
// at evalScript (node:internal/process/execution:76:60)
// at node:internal/main/eval_string:23:3
const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);
myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err
const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err

@see ― source

console
.
Console.log(message?: any, ...optionalParams: any[]): void (+1 overload)

Prints to stdout with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const count = 5;
console.log('count: %d', count);
// Prints: count: 5, to stdout
console.log('count:', count);
// Prints: count: 5, to stdout

See util.format() for more information.

@since ― v0.1.100

log
({
limit: number
limit
:
const provider: AdaptiveLimitProvider
provider
.
AdaptiveLimitProvider.limit: number
limit
,
inflight: number
inflight
:
const provider: AdaptiveLimitProvider
provider
.
AdaptiveLimitProvider.inflight: number
inflight
,
queued: number
queued
:
const provider: AdaptiveLimitProvider
provider
.
AdaptiveLimitProvider.queued: number
queued
}), 10_000);
Telemetry event Callback When
OnLimitChanged onLimitChanged The whole-number limit changed. Args: { oldLimit, newLimit, sample }.
OnRateLimiterRejected onRejected An execution was rejected.

Rejections are BulkheadRejectedError (code: 'BULKHEAD_FULL', HTTP 503) with reason 'full' or 'queue-timeout', and the active / queued counts. See the concurrency limiter.

  • Set maxLimit to what you can afford. The limiter protects the dependency, not your process. Keep maxLimit at or below your connection pool size.
  • Custom drops. If the dependency signals overload in a response (HTTP 429 or 503 returned as a value), say so with shouldDrop: ({ outcome }) => !outcome.ok || outcome.result.status === 503.
  • Slower forever. Both latency algorithms remember the fastest latency. If the dependency gets permanently slower (2× at low load), the limit settles lower. Restart or recreate the pipeline after such a change.