Reliability: retry, timeout, race
Reliability — barrel export.
Re-exports all retry/timeout/concurrency helpers for production-grade ROP pipelines.
Interfaces
Section titled “Interfaces”AbortedError
Section titled “AbortedError”Defined in: reliability/retry.ts:80
Default shape of the error produced when the retry loop never invokes fn
(pre-aborted signal, or a non-finite / negative times).
Properties
Section titled “Properties”| Property | Modifier | Type |
|---|---|---|
kind |
readonly |
"Aborted" |
reason |
readonly |
unknown |
times |
readonly |
number |
EmptyInputsError
Section titled “EmptyInputsError”Defined in: reliability/race.ts:10
Default shape of the error produced by race when the input array is empty.
Library consumers can extend, narrow, or replace it via the onEmpty hook.
Properties
Section titled “Properties”| Property | Modifier | Type |
|---|---|---|
kind |
readonly |
"EmptyInputs" |
RetryOptions
Section titled “RetryOptions”Defined in: reliability/retry.ts:17
Options for retry and retryLazy.
Each field has a sensible default; only set the knobs you actually need.
Type Parameters
Section titled “Type Parameters”| Type Parameter | Default type | Description |
|---|---|---|
E |
unknown |
— the error type your fn returns in its Err. |
TE |
ThrownError |
— the error produced when fn (or one of these hooks) throws. Defaults to ThrownError; override with onThrow. |
AE |
AbortedError |
— the error produced when the loop never runs fn at all. Defaults to AbortedError; override with onAborted. |
Properties
Section titled “Properties”| Property | Modifier | Type | Description |
|---|---|---|---|
delayMs? |
readonly |
number | ((attempt, error) => number) |
Delay between attempts in milliseconds. Either a fixed number or a function of (zero-based attempt index, last error). Default 0 (no delay). Negative values are clamped to 0. |
onAborted? |
readonly |
(reason, times) => AE |
Optional factory invoked when the retry loop exits without ever calling fn (pre-aborted signal or non-finite / negative times). The returned value becomes the error of the resolved Err. Without it the library falls back to AbortedError. |
onRetry? |
readonly |
(error, attempt) => void |
Optional hook invoked after shouldRetry approves a retry and before the backoff delay begins. Useful for logging or metrics (“will retry in Nms”); the value it returns is ignored. |
onThrow? |
readonly |
(thrown) => TE |
Optional factory that converts a value thrown by fn (or by one of the hooks above) into your own error type, collapsing E | TE back to E. Without it the library preserves the thrown value verbatim inside a ThrownError. |
shouldRetry? |
readonly |
(error, attempt) => boolean |
Predicate that decides whether to retry after a given failure. Return false to stop retrying immediately and return the last result. Default: always retry. Receives E | TE because a throw from fn is a real failure your policy has to classify — supply onThrow to collapse both into one shape. |
signal? |
readonly |
AbortSignal |
Abort signal. If signal.aborted becomes true during the delay window, the loop exits and the last result is returned (the supplied function is never re-invoked past that point). |
times? |
readonly |
number |
Maximum retry attempts (excluding the first attempt). Default 3. Fractional values are floored — times: 2.7 performs 3 attempts total. |
ThrownError
Section titled “ThrownError”Defined in: reliability/retry.ts:71
Default shape of the error produced when fn throws instead of returning an
Err. The original thrown value is preserved verbatim in thrown, so the
Error instance, its stack and its cause all survive.
Properties
Section titled “Properties”| Property | Modifier | Type |
|---|---|---|
kind |
readonly |
"Thrown" |
thrown |
readonly |
unknown |
TimeoutError
Section titled “TimeoutError”Defined in: reliability/timeout.ts:11
Default shape of the error produced by timeout when no factory is given.
Library consumers can extend, narrow, or replace it via the onTimeout hook.
Properties
Section titled “Properties”| Property | Modifier | Type |
|---|---|---|
kind |
readonly |
"Timeout" |
ms |
readonly |
number |
Type Aliases
Section titled “Type Aliases”AnyError
Section titled “AnyError”AnyError<
E> =E| {error:unknown;kind:"Rejected"; }
Defined in: reliability/any.ts:13
Tagged envelope for an inner .run() Promise rejection. any collects
these alongside Err values so the rejection’s actual shape (which can be
string, undefined, …) is preserved as unknown instead of being
forced into the caller’s E union.
Type Parameters
Section titled “Type Parameters”| Type Parameter |
|---|
E |
Settled
Section titled “Settled”Settled<
T,E> = {error?:never;ok:true;value:T; } | {error:E;kind?:never;ok:false;value?:never; } | {error:unknown;kind:"Rejected";ok:false;value?:never; }
Defined in: reliability/allSettled.ts:22
Discriminated outcome of a single thunk in an allSettled batch.
Three variants - discriminated by ok and, for the rejection case, by the
kind tag:
{ ok: true, value: T }- the thunk resolved withOk.{ ok: false, error: E }- the thunk resolved withErr. Nokindtag is set for this variant -kindis reserved for the rejection envelope.{ ok: false, kind: 'Rejected', error: unknown }- the inner.run()rejected the Promise (an upstream contract violation thatallSettleddefends against). The rejection value is preserved verbatim asunknownso consumers can narrow onkindbefore readingerror. This avoids the type lie where rejection values (which can bestring,undefined, or anything else) were cast into the user’sEunion.
Type Parameters
Section titled “Type Parameters”| Type Parameter |
|---|
T |
E |
Functions
Section titled “Functions”allSettled()
Section titled “allSettled()”allSettled — never short-circuits. Collects every thunk’s outcome into a
discriminated array that mirrors the input order. Always returns Ok with the
collected list, so observability and batch coordination layers can log
everything without losing partial successes.
Run every thunk; the result is always Ok([...settled, ...in input order]).
Unhandled rejections are captured as { ok: false, error: rejection } rather than
propagated.
export function allSettled<T, E>( results: readonly AsyncResult<T, E>[],): AsyncResult<Settled<T, E>[], never>;Defined in: reliability/allSettled.ts
Example
Section titled “Example”import { allSettled } from '@sandlada/result/reliability';import { fromResult } from '@sandlada/result/async-result';import { ok, err } from '@sandlada/result/factories';import { match } from '@sandlada/result/operators';
const thunks = [fromResult(ok(1)), fromResult(err('a')), fromResult(ok(2))];const ar = allSettled(thunks);const batch = await ar.run();const okCount = match((rows) => rows.filter((row) => row.ok).length, () => 0, batch);// batch is always Ok([...]) in input order; okCount === 2 — one Err row does not fail the batchPromise.any-style combination: succeeds with all collected successes (in completion order), or fails with every collected error (in completion order).
- If at least one thunk resolves with
Ok, the final result isOk([...all-successes]). - If every thunk resolves with
Err, the final result isErr([...all-errors]).
any is lazy and does not short-circuit — all thunks are guaranteed to run.
Order: successes and errors are populated in the order their underlying
runs settle (microtask scheduling), not input order. Use allSettled if input
order matters.
export function any<T, E>( results: readonly AsyncResult<T, E>[],): AsyncResult<T[], AnyError<E>[]>;Defined in: reliability/any.ts
Example
Section titled “Example”import { any } from '@sandlada/result/reliability';import { fromResult } from '@sandlada/result/async-result';import { ok, err } from '@sandlada/result/factories';import { match } from '@sandlada/result/operators';
const thunks = [fromResult(ok(1)), fromResult(err('a')), fromResult(ok(2))];const ar = any(thunks);const outcome = await ar.run();const label = match((values) => `${values.length} ok`, (errors) => `failed:${errors.length}`, outcome);// outcome is Ok([1, 2]); label === '2 ok' — partial successes are collected
const allErr = await any([fromResult(err('x')), fromResult(err('y'))]).run();// allErr is Err(['x', 'y']) when every thunk resolves with Errrace()
Section titled “race()”First-success-wins combination of AsyncResults. Returns the first thunk to
resolve with Ok; if every thunk resolves with Err, returns the first error
(in input order). Lazy — none of the inputs run until .run() is called.
Empty input: if results is empty, there is no input value or input error to
propagate, so race produces an error of its own. Rather than fabricating a value
of the caller’s E (which would be a type lie), the error type is widened to
E | EE, where EE defaults to EmptyInputsError. Supply onEmpty to
substitute a domain-specific error instead.
That widening is charged only where it can actually happen: passing a literal
non-empty array proves the empty branch unreachable, so the caller keeps a clean
AsyncResult<T, E>. Only a dynamically-sized array pays the | EE.
Selection policy — total and deterministic, independent of arrival order except where a race inherently depends on it:
- The first run to resolve
Okwins, regardless of input order. - If no run succeeds, a genuine
Erralways outranks a Promise rejection: a rejection breaks theAsyncResultcontract (which requires resolving toOk/Err) and therefore signals an upstream bug, not a domain outcome. - Among
Errs, the lowest input index wins, even if it settles last. - If every run rejected, the earliest-arriving rejection wins.
- For “first to settle, whatever it is” semantics, use a separate primitive.
Type lie: when every run rejected, the resulting Err.error is the
unknown rejection reason (cast to E in the implementation). The
static type still says E | EE. Consumers that need to distinguish an
upstream rejection from a domain Err should narrow at runtime
(e.g. (r.error as { message?: unknown }).message !== undefined or
instanceof Error). The cast is intentional: a true unknown here
would erase all type information from the success path’s E.
export function race<T, E>( results: readonly [AsyncResult<T, E>, ...AsyncResult<T, E>[]],): AsyncResult<T, E>;export function race<T, E, EE = EmptyInputsError>( results: readonly AsyncResult<T, E>[], onEmpty?: () => EE,): AsyncResult<T, E | EE>;Defined in: reliability/race.ts
Examples
Section titled “Examples”import { race } from '@sandlada/result/reliability';import { fromResult } from '@sandlada/result/async-result';import { ok, err } from '@sandlada/result/factories';import { unwrapOr } from '@sandlada/result/operators';
const candidates = [fromResult(err('slow')), fromResult(ok(1))];const ar = race(candidates);const winner = await ar.run();const value = unwrapOr(0, winner);// winner is Ok(1); value === 1 — first Ok wins even when an earlier slot returned ErrDiscriminating the empty-input case
import { race } from '@sandlada/result/reliability';import { match } from '@sandlada/result/operators';
const ar = race<number, never, { readonly tag: 'NoCandidates' }>([], () => ({ tag: 'NoCandidates' }));const empty = await ar.run();const tag = match(() => 'unexpected', (e) => e.tag, empty);// empty is Err({ tag: 'NoCandidates' }); tag === 'NoCandidates' — onEmpty fills the second argumentretry()
Section titled “retry()”Retries a fallible function with configurable attempts, backoff, and predicate gating.
retry is eager: it returns Promise<IResultOfT<T, E>> and runs the supplied
function up to times + 1 times. Use shouldRetry to filter transient errors
(timeouts, network blips) and signal to abort the retry loop.
Synchronous throws AND promise rejections from fn are converted to Err,
as are throws escaping shouldRetry, onRetry, delayMs, onThrow and
onAborted. The returned promise therefore never rejects — matching the
AsyncResult contract used elsewhere in the library.
The retry loop respects AbortSignal between attempts only; it cannot
interrupt an in-flight invocation.
Error identity: a value thrown by fn is preserved verbatim inside a
ThrownError ({ kind: 'Thrown', thrown }), so the original Error
instance, its stack and its cause all survive. Pass onThrow to map the
throw onto your own error type instead.
Aborted / no-attempt cases: when the loop never invokes fn (a
pre-aborted signal or a non-finite / negative times), the resolved Err
carries an AbortedError ({ kind: 'Aborted', reason, times }).
Pass onAborted to substitute your own shape.
Error channels — the resolved error is E | TE | AE, where each arm is
separately discriminable and separately collapsible:
E— anErryourfnreturned.TE— something threw. Defaults to ThrownError, which keeps the thrown value verbatim; passonThrowto fold it intoE.AE— the loop never ranfnat all. Defaults to AbortedError; passonAbortedto fold it intoE.
If a caller-supplied onThrow / onAborted factory itself throws, the
library falls back to the corresponding default sentinel rather than
rejecting — a broken factory must not take the whole contract down.
export async function retry<T, E, TE = ThrownError, AE = AbortedError>( fn: () => IResultOfT<T, E> | Promise<IResultOfT<T, E>>, options: RetryOptions<E, TE, AE> = {},): Promise<IResultOfT<T, E | TE | AE>>;Defined in: reliability/retry.ts
Examples
Section titled “Examples”import { retry } from '@sandlada/result/reliability';import { ok, err } from '@sandlada/result/factories';import { match } from '@sandlada/result/operators';
let tries = 0;const fn = () => { tries += 1; return tries < 2 ? err('transient') : ok(42);};const result = await retry(fn, { times: 3, delayMs: 0 });const label = match((n) => `value:${n}`, (e) => String(e), result);// result is Ok(42) after one retry; exhausted attempts stay Err with the last errorCollapsing every channel onto one domain error
import { retry } from '@sandlada/result/reliability';import { ok } from '@sandlada/result/factories';import { unwrapOr } from '@sandlada/result/operators';
type MyError = | { readonly kind: 'Unexpected'; readonly thrown: unknown } | { readonly kind: 'Cancelled'; readonly reason: unknown };
const controller = new AbortController();controller.abort();const r = await retry<number, MyError, MyError, MyError>( () => Promise.resolve(ok(42)), { signal: controller.signal, onThrow: (thrown) => ({ kind: 'Unexpected', thrown }), onAborted: (reason) => ({ kind: 'Cancelled', reason }), },);const fallback = unwrapOr(0, r);// r is Err({ kind: 'Cancelled', ... }); fallback === 0 — pre-aborted signal never runs fnretryLazy()
Section titled “retryLazy()”Lazy counterpart to retry — wraps an AsyncResult and defers
execution until the returned thunk is run(). Use when an existing AsyncResult
pipeline should retry transparently without changing upstream code.
Error channels mirror the eager retry: E | TE | AE, where TE
covers throws and AE covers the never-ran case. Supply onThrow /
onAborted to collapse them onto your own error type.
Error identity: like retry, a thrown value is preserved verbatim
inside a ThrownError ({ kind: 'Thrown', thrown }) — the original Error
instance, its stack and its cause all survive. Pass onThrow to map the
throw onto your own error type.
Attempt numbering: the attempt parameter passed to shouldRetry and
onRetry is zero-based (0 = first retry attempt after the initial call).
The total number of invocations is options.times + 1 (initial + retries).
export function retryLazy<T, E, TE = ThrownError, AE = AbortedError>( ar: AsyncResult<T, E>, options: RetryOptions<E, TE, AE> = {},): AsyncResult<T, E | TE | AE>;Defined in: reliability/retryLazy.ts
Example
Section titled “Example”import { retryLazy } from '@sandlada/result/reliability';import { fromPromise } from '@sandlada/result/async-result';import { match } from '@sandlada/result/operators';
let attempt = 0;const work = fromPromise(() => { attempt += 1; return attempt >= 2 ? Promise.resolve(99) : Promise.reject('flaky');});const pipeline = retryLazy(work, { times: 3, delayMs: 0 });const result = await pipeline.run();const value = match((n) => n, (e) => 0, result);// result is Ok(99) after retries; persistent failures stay Err — work is the AsyncResult first argumenttimeout()
Section titled “timeout()”Races an AsyncResult against a timeout. If the inner result does not settle
before ms milliseconds have elapsed, the returned AsyncResult resolves to an
Err produced by the optional onTimeout factory. The default factory yields
{ kind: 'Timeout', ms }.
timeout is lazy — it never triggers ar.run() until .run() is invoked.
Duration bounds: a timer can only express a bounded delay. Node clamps
anything it cannot represent to 1 ms and emits a TimeoutOverflowWarning
/ TimeoutNaNWarning, so an un-clamped Infinity would expire immediately -
the opposite of what the caller asked for. The duration is therefore clamped
into the range a timer can honour (Infinity becomes the maximum timer
interval, NaN and negative values become zero), and the effective
duration is the one reported to onTimeout and carried by the default
{ kind: 'Timeout', ms } error.
Cancellation caveat: JavaScript Promises cannot be forcibly cancelled.
When the timer fires first, timeout returns Err(onTimeout(ms)) but the
inner ar.run() continues executing in the background — its eventual
settlement is discarded. For long-running or resource-heavy inner work
(network I/O, file I/O, large computations), this is a resource leak. If
cooperative cancellation is required, wrap the inner work in a cancellable
primitive (e.g. one that listens to an AbortSignal) before passing it to
timeout.
export function timeout<T, E, TOE = TimeoutError>( ms: number, ar: AsyncResult<T, E>, onTimeout: (ms: number) => TOE = defaultOnTimeout as (ms: number) => TOE,): AsyncResult<T, E | TOE>;Defined in: reliability/timeout.ts
Example
Section titled “Example”import { timeout } from '@sandlada/result/reliability';import { fromPromise, fromResult } from '@sandlada/result/async-result';import { ok } from '@sandlada/result/factories';import { match } from '@sandlada/result/operators';
const fast = fromResult(ok('ready'));const ar = timeout(60_000, fast);const within = await ar.run();const label = match((v) => `got:${v}`, (e) => e.kind, within);// within is Ok('ready'); label === 'got:ready' — inner finished before the timer fired
const slow = fromPromise(() => new Promise<string>(() => {}));const capped = timeout(0, slow);const timedOut = await capped.run();// timedOut is Err({ kind: 'Timeout', ms: 0 }) when the inner run outlasts the windowtimeoutEager()
Section titled “timeoutEager()”Eager counterpart to timeout. Wraps a () => Promise<IResultOfT>
to short-circuit slow runs into Err(onTimeout(ms)). Use this instead of
timeout when the upstream API returns a Promise<IResultOfT> rather than a
lazy AsyncResult thunk.
Accepts a () => Promise<IResultOfT<T, E>> and races it against the configured
timeout window. Reuses the same default TimeoutError shape as timeout, and
the same duration bounds: a non-finite or out-of-range ms is clamped before
it reaches the timer, so an Infinity deadline no longer expires after 1 ms.
Sync-throw safety: a synchronous throw from fn is converted to a
rejecting Promise via Promise.resolve().then(fn), which timeout’s
rejection handler then converts to Err(thrown) — preserving the
AsyncResult no-rejection contract.
export function timeoutEager<T, E, TOE = TimeoutError>( ms: number, fn: () => Promise<IResultOfT<T, E>>, onTimeout?: (ms: number) => TOE,): Promise<IResultOfT<T, E | TOE>>;Defined in: reliability/timeoutEager.ts
Example
Section titled “Example”import type { IResultOfT } from '@sandlada/result/types';import { timeoutEager } from '@sandlada/result/reliability';import { asyncOk } from '@sandlada/result/factories';import { match } from '@sandlada/result/operators';
const input = asyncOk('payload');const within = await timeoutEager(60_000, () => input);const label = match((v) => `ok:${v}`, (e) => e.kind, within);// within is Ok('payload'); label === 'ok:payload' — fn supplies the eager Promise carrier
const never: () => Promise<IResultOfT<string, never>> = () => new Promise(() => {});const capped = await timeoutEager(0, never);// capped is Err({ kind: 'Timeout', ms: 0 }) when fn's Promise does not settle in time