Skip to content

Reliability: retry, timeout, race

Reliability — barrel export.

Re-exports all retry/timeout/concurrency helpers for production-grade ROP pipelines.

Defined in: reliability/retry.ts:78

Default shape of the error produced when the retry loop never invokes fn (pre-aborted signal, or a non-finite / negative times).

Property Modifier Type
kind readonly "Aborted"
reason readonly unknown
times readonly number

Defined in: reliability/race.ts:9

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.

Property Modifier Type
kind readonly "EmptyInputs"

Defined in: reliability/retry.ts:15

Options for retry and retryLazy.

Each field has a sensible default; only set the knobs you actually need.

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.
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
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
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.

Defined in: reliability/retry.ts:69

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.

Property Modifier Type
kind readonly "Thrown"
thrown readonly unknown

Defined in: reliability/timeout.ts:8

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.

Property Modifier Type
kind readonly "Timeout"
ms readonly number

Settled<T, E> = { error?: never; ok: true; value: T; } | { error: E; kind?: "Err"; ok: false; value?: never; } | { error: unknown; kind: "Rejected"; ok: false; value?: never; }

Defined in: reliability/allSettled.ts:20

Discriminated outcome of a single thunk in an allSettled batch.

Three variants — discriminated by ok and, for failures, by the kind tag:

  • { ok: true, value: T } — the thunk resolved with Ok.
  • { ok: false, error: E } — the thunk resolved with Err.
  • { ok: false, kind: 'Rejected', error: unknown } — the inner .run() rejected the Promise (an upstream contract violation that allSettled defends against). The rejection value is preserved verbatim as unknown so consumers can narrow on kind before reading error. This avoids the type lie where rejection values (which can be string, undefined, or anything else) were cast into the user’s E union.
Type Parameter
T
E

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

import { allSettled } from '@sandlada/result/reliability';
import { fromResult } from '@sandlada/result/async-result';
import { ok, err } from '@sandlada/result/factories';
const ar = allSettled([fromResult(ok(1)), fromResult(err('a')), fromResult(ok(2))]);
const r = await ar.run();
// Ok([
// { ok: true, value: 1 },
// { ok: false, error: 'a' },
// { ok: true, value: 2 },
// ])

Promise.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 is Ok([...all-successes]).
  • If every thunk resolves with Err, the final result is Err([...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

import { any } from '@sandlada/result/reliability';
import { fromResult } from '@sandlada/result/async-result';
import { ok, err } from '@sandlada/result/factories';
const ar = any([fromResult(ok(1)), fromResult(err('a')), fromResult(ok(2))]);
const r = await ar.run(); // Ok([1, 2]) — partial success collected.

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 Ok wins, regardless of input order.
  • If no run succeeds, a genuine Err always outranks a Promise rejection: a rejection breaks the AsyncResult contract (which requires resolving to Ok/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

import { race } from '@sandlada/result/reliability';
import { fromResult } from '@sandlada/result/async-result';
import { ok, err } from '@sandlada/result/factories';
const ar = race([fromResult(ok(1)), fromResult(err('a'))]);
const r = await ar.run(); // Ok(1) — first success wins.

Discriminating the empty-input case

import { race } from '@sandlada/result/reliability';
import { fromResult } from '@sandlada/result/async-result';
import { ok } from '@sandlada/result/factories';
const single = await race([fromResult(ok(1))]).run(); // Ok(1)
// …or map the empty-input case onto your own error union:
const custom = await race<number, Error, { readonly tag: 'NoCandidates' }>(
[],
() => ({ tag: 'NoCandidates' }),
).run();

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 — an Err your fn returned.
  • TE — something threw. Defaults to ThrownError, which keeps the thrown value verbatim; pass onThrow to fold it into E.
  • AE — the loop never ran fn at all. Defaults to AbortedError; pass onAborted to fold it into E.

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

import { retry } from '@sandlada/result/reliability';
import { ok, err } from '@sandlada/result/factories';
const r = await retry(
() => Promise.resolve(ok(42)),
{ times: 3, delayMs: n => 50 * (n + 1) }, // linear backoff
);
// Ok(42); on failure r.error: E | ThrownError | AbortedError — each narrowable.

Collapsing every channel onto one domain error

import { retry } from '@sandlada/result/reliability';
import { ok } from '@sandlada/result/factories';
type MyError =
| { readonly kind: 'Unexpected'; readonly thrown: unknown }
| { readonly kind: 'Cancelled'; readonly reason: unknown };
const r = await retry<number, MyError, MyError, MyError>(
() => Promise.resolve(ok(42)),
{
onThrow: (thrown) => ({ kind: 'Unexpected', thrown }),
onAborted: (reason) => ({ kind: 'Cancelled', reason }),
},
);
// r.error: MyError

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

import { retryLazy } from '@sandlada/result/reliability';
import { fromPromise } from '@sandlada/result/async-result';
import { ok } from '@sandlada/result/factories';
const pipeline = retryLazy(fromPromise(() => Promise.resolve(ok(42))), { times: 3 });
const r = await pipeline.run();

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.

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

import { timeout } from '@sandlada/result/reliability';
import { fromPromise } from '@sandlada/result/async-result';
const safe = timeout(2000, fromPromise(() => fetch('/slow').then(r => r.json())));
const r = await safe.run();
// r is Ok(...) if fetch completed within 2000ms, else Err({ kind: 'Timeout', ms: 2000 }).

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.

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

import { timeoutEager } from '@sandlada/result/reliability';
import { fromPromise } from '@sandlada/result/factories';
const r = await timeoutEager(2000, () => fromPromise(fetch('/slow')));