The problem. Retries, timeouts, breakers and fallbacks are configured once and then never exercised until a real outage.
By then a timeout is longer than the load balancer’s, a fallback throws, or the breaker never opens because its
minimumThroughput is higher than the traffic. A game day finds those gaps on purpose: you inject a failure at a
time you choose, with people watching and a way to stop it, and check that the system behaves as you claimed.
The library gives you two ways to inject:
Chaos strategies (addChaosFault …)
setGlobalChaos()
Where
In one pipeline’s configuration
Every pipeline in the process
Needs a deploy
Once, to pre-wire them (off by default)
No: call it at runtime
Scope
Per pipeline, per execution (enabledGenerator, injectionRateGenerator)
One filter over every callback attempt
Injects
Fault, outcome (fake result), latency, any behaviour
Fault and/or latency
Telemetry
Chaos.OnFault, Chaos.OnLatency, … with the pipeline name
Chaos.OnGlobal, without a pipeline name
Use pre-wired strategies for the experiments you’ll run again and again; use setGlobalChaos for one-off questions in
staging (“what if every database call is 2 s slower?”).
Write the hypothesis. One sentence you can check: “When pricing returns 503 for 20% of calls, checkout success
stays above 99.5% and p99 latency below 1.5 s, because the retry hides single failures.”
Define steady state and the abort condition. The metrics that show the hypothesis holds (checkout success rate,
p99), and the line at which you stop at once (success below 98%). Look at them for a normal hour first.
Limit the blast radius. Staging first. In production: synthetic traffic or an internal tenant only, one instance or
one pipeline, a low rate, and a fixed end time.
Make stopping trivial and automatic. A flag that turns injection off without a deploy, and a timer that turns it off
even if nobody does.
Run, watch, and write down what happened. Ramp the rate in steps (1%, 5%, 20%). Watch the Chaos.* events next to the
steady-state metrics, so you always know which errors you caused.
Review. Every surprise becomes a ticket: a timeout to shorten, a fallback to fix, a breaker threshold to lower.
Then turn the experiment into an automated test so it can’t regress.
Add chaos strategies last (innermost), so the retry, breaker, timeout and fallback above them see the injected failure as
if it came from the dependency. Gate them twice: a flag that enables the experiment, and a rate that is zero for everything
outside its scope.
pricing.ts
/** Set on synthetic and internal-tenant requests: the only traffic a game day may touch. */
exportconst
constCHAOS_SCOPE:"chaos-scope"
Set on synthetic and internal-tenant requests: the only traffic a game day may touch.
CHAOS_SCOPE='chaos-scope';
const
constrate: (flag:string) => ({ context }: {
context: {
properties:Map<string, unknown>;
};
}) =>number
rate= (
flag: string
flag:string) => ({
context: {
properties: Map<string, unknown>;
}
context }: {
context: {
properties: Map<string, unknown>;
}
context: {
properties: Map<string, unknown>
properties:
interfaceMap<K, V>
Map<string, unknown> } }) =>
context: {
properties: Map<string, unknown>;
}
context.
properties: Map<string, unknown>
properties.
Map<string, unknown>.has(key: string): boolean
@returns ― boolean indicating whether an element with the specified key exists or not.
has(
constCHAOS_SCOPE:"chaos-scope"
Set on synthetic and internal-tenant requests: the only traffic a game day may touch.
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().
With both flags off, the strategies cost one flag lookup per call. The fault carries status: 503, so the default retry and
breaker predicates treat it exactly like a real 503. A fault with status: 400 would test something else: that client
errors are not retried.
Experiments worth pre-wiring for each important dependency:
Experiment
Inject
You expect
Slow dependency
addChaosLatency above the timeout
TimeoutRejectedError, retry, then fallback; inbound p99 stays under its budget.
Partial failure
addChaosFault, 5–20%
Retries hide it; error rate seen by users barely moves.
Dependency down
addChaosFault, 100% for scoped traffic
Breaker opens within minimumThroughput calls; fallback answers; no retry storm.
Rate limited
addChaosOutcome with a 429 result, or a fault with status: 429 and retryAfter
Calls wait for retryAfter; nothing hammers the API.
Slow, then down
latency at a low rate plus faults
Adaptive limits shrink, shedding starts before the timeouts pile up.
setGlobalChaos adds latency and/or a fault before every callback attempt of every pipeline in the process, without
touching their configuration. It returns a function that turns it off. Wrap it so it always turns itself off:
Inject faults and/or latency into every pipeline in the process, without touching their configuration:
game days, staging soak tests, "does my whole app survive a slow database?". Returns a function that turns it off.
setGlobalChaos({
GlobalChaosOptions.injectionRate?: number |undefined
Probability per callback in [0, 1]. Default 0.05.
injectionRate:
options: {
keyPrefix: string;
rate: number;
latency?: Duration;
fail?: boolean;
minutes: number;
}
options.
rate: number
rate,
GlobalChaosOptions.latency?: Duration |undefined
Delay added before the callback.
latency:
options: {
keyPrefix: string;
rate: number;
latency?: Duration;
fail?: boolean;
minutes: number;
}
options.
latency?: Duration |undefined
latency,
GlobalChaosOptions.fault?: unknown
Error to fail with (or a function creating it). Omit to inject latency only.
fault:
options: {
keyPrefix: string;
rate: number;
latency?: Duration;
fail?: boolean;
minutes: number;
}
options.
fail?: boolean |undefined
fail? () =>
var Object:ObjectConstructor
Provides functionality common to all JavaScript objects.
Object.
ObjectConstructor.assign<Error, {
status: number;
}>(target:Error, source: {
status:number;
}):Error& {
status:number;
} (+3overloads)
Copy the values of all of the enumerable own properties from one or more source objects to a
target object. Returns the target object.
@param ― target The target object to copy to.
@param ― source The source object from which to copy properties.
assign(new
var Error:ErrorConstructor
new (message?:string, options?:ErrorOptions) =>Error (+1 overload)
Error('chaos: game day'), {
status: number
status: 503 }) :
var undefined
undefined,
// Only calls that opted in through their operationKey. Never "everything".
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
Schedules execution of a one-time callback after delay milliseconds.
The callback will likely not be invoked in precisely delay milliseconds.
Node.js makes no guarantees about the exact timing of when callbacks will fire,
nor of their ordering. The callback will be called as close as possible to the
time specified.
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.setTimeout().
@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.
@param ― timeout A Timeout object as returned by setTimeout()
or the primitive of the Timeout object as a string or a number.
clearTimeout(
let timer:NodeJS.Timeout|undefined
timer);
let stop: (() =>void) |undefined
stop?.();
let stop: (() =>void) |undefined
stop=
var undefined
undefined;
}
// From an authenticated admin endpoint, in staging:
functionstartGameDay(options: {
keyPrefix:string;
rate:number;
latency?:Duration;
fail?:boolean;
minutes:number;
}):void
Starts an experiment that ends by itself after minutes, even if nobody calls stopGameDay().
startGameDay({
keyPrefix: string
keyPrefix: 'db:',
rate: number
rate: 0.2,
latency?: Duration |undefined
latency: '2s',
minutes: number
minutes: 15 });
The filter sees the execution’s context: operationKey, operationName (the name execute option), properties
and attemptNumber. It does not see which pipeline is running, so give the calls you want to target a recognisable
operationKey or property. (operationKey is also the default cache key, so on cached pipelines use a property instead.)
Returns true if the sequence of elements of searchString converted to a String is the
same as the corresponding elements of this object (converted to a String) starting at
position. Otherwise returns false.
If you use the OpenTelemetry or Prometheus integration, the
Chaos.* events are already counted in the strategy events metric. Put that panel on the game-day dashboard.
What to look for:
Injected, but no reaction. Faults injected and no OnRetry: the retry predicate doesn’t match the error. Latency
injected and no OnTimeout: the timeout is longer than the injected latency.
Reaction, but users still affected. Timeouts fire, yet inbound p99 climbs: the retries add up to more than the request
budget. Shorten per-attempt timeouts or retry less.
Breaker never opens. Scoped traffic is below minimumThroughput within samplingDuration. Either the breaker is tuned for
more traffic than you have, or the experiment’s scope is too small to test it.
Have the stop actions written down before you start, fastest first:
Turn the experiment flag off (pre-wired strategies) or call stopGameDay() (global chaos).
If an instance is stuck in a bad state (circuit open on a healthy dependency), close it through its
CircuitBreakerManualControl, or restart the instance.
The timer ends a global experiment by itself; a restart ends it too, since the hook lives in memory.
The test() function is the value imported from the test module. Each
invocation of this function results in reporting the test to the TestsStream.
The TestContext object passed to the fn argument can be used to perform
actions related to the current test. Examples include skipping the test, adding
additional diagnostic information, or creating subtests.
test() returns a Promise that fulfills once the test completes.
if test() is called within a suite, it fulfills immediately.
The return value can usually be discarded for top level tests.
However, the return value from subtests should be used to prevent the parent
test from finishing first and cancelling the subtest
as shown in the following example.
test('top level test', async (t) => {
// The setTimeout() in the following subtest would cause it to outlive its
// parent test if 'await' is removed on the next line. Once the parent test
// completes, it will cancel any outstanding subtests.
The timeout option can be used to fail the test if it takes longer than timeout milliseconds to complete. However, it is not a reliable mechanism for
canceling tests because a running test might block the application thread and
thus prevent the scheduled cancellation.
@since ― v18.0.0, v16.17.0
@param ― name The name of the test, which is displayed when reporting test results.
Defaults to the name property of fn, or '<anonymous>' if fn does not have a name.
@param ― options Configuration options for the test.
@param ― fn The function under test. The first argument to this function is a TestContext object.
If the test uses callbacks, the callback function is passed as the second argument.
@return ― Fulfilled with undefined once the test completes, or immediately if the test runs within a suite.
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().
The test() function is the value imported from the test module. Each
invocation of this function results in reporting the test to the TestsStream.
The TestContext object passed to the fn argument can be used to perform
actions related to the current test. Examples include skipping the test, adding
additional diagnostic information, or creating subtests.
test() returns a Promise that fulfills once the test completes.
if test() is called within a suite, it fulfills immediately.
The return value can usually be discarded for top level tests.
However, the return value from subtests should be used to prevent the parent
test from finishing first and cancelling the subtest
as shown in the following example.
test('top level test', async (t) => {
// The setTimeout() in the following subtest would cause it to outlive its
// parent test if 'await' is removed on the next line. Once the parent test
// completes, it will cancel any outstanding subtests.
The timeout option can be used to fail the test if it takes longer than timeout milliseconds to complete. However, it is not a reliable mechanism for
canceling tests because a running test might block the application thread and
thus prevent the scheduled cancellation.
@since ― v18.0.0, v16.17.0
@param ― name The name of the test, which is displayed when reporting test results.
Defaults to the name property of fn, or '<anonymous>' if fn does not have a name.
@param ― options Configuration options for the test.
@param ― fn The function under test. The first argument to this function is a TestContext object.
If the test uses callbacks, the callback function is passed as the second argument.
@return ― Fulfilled with undefined once the test completes, or immediately if the test runs within a suite.
test('pricing answers from the fallback when pricing is down', async () => {
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().
// AssertionError [ERR_ASSERTION]: apples 1 !== oranges 2
assert.strictEqual(1, '1', newTypeError('Inputs are not identical'));
// TypeError: Inputs are not identical
If the values are not strictly equal, an AssertionError is thrown with a message property set equal to the value of the message parameter. If the message parameter is undefined, a
default error message is assigned. If the message parameter is an instance of an Error then it will be thrown
instead of the AssertionError.
Runs the callback through the pipeline; throws the final error.
execute(() =>42), -1);
});
Prefer the per-pipeline strategies in tests over setGlobalChaos: test runners run files in parallel, and the global hook
is shared by everything in the process. For injected latency, use a FakeTimeProvider so a 2 s delay takes no time (see
testing).