Skip to content
English

Retry

retry is a pure async function for “try this call up to N times, with backoff between attempts.” It’s the simplest layer in the resilience stack — no actors, no state, just a Promise-returning factory.

import { retry } from 'actor-ts';
const data = await retry(
() => fetch('https://example.com/items').then(r => r.json()),
{ attempts: 3, delayMs: 200, factor: 2 },
);

The factory is called up to 3 times. Each retry waits longer: attempt 1 immediately, attempt 2 after 200 ms, attempt 3 after 400 ms. Returns the first success; rejects with the last error if every attempt fails.

function retry<T>(factory: () => Promise<T>, options: RetryOptions): Promise<T>;
type RetryOptions = {
attempts: number; // total including initial call (>= 1)
delayMs?: number; // base delay between attempts
factor?: number; // multiplier for exponential backoff (default 1)
maxDelayMs?: number; // ceiling for any individual delay
shouldRetry?: (err: Error, attempt: number) => boolean;
onAttempt?: (err: Error, attempt: number) => void;
sleep?: (ms: number) => Promise<void>; // how the delay is awaited
};

The attempts field is total attempts, not retries — attempts: 1 runs the factory exactly once with no retries. attempts: 3 runs up to 3 times.

Three knobs cooperate to control the delay between attempts:

SettingWhat it does
delayMsBase delay between attempts. Default 0 (no wait).
factorMultiplier per attempt. Delay on attempt N is delayMs × factor^(N-1). Default 1 (constant).
maxDelayMsUpper bound on any single delay. Default unbounded.
// Constant 500ms between attempts:
retry(factory, { attempts: 5, delayMs: 500 });
// Exponential: 200ms, 400ms, 800ms, capped at 5s:
retry(factory, { attempts: 6, delayMs: 200, factor: 2, maxDelayMs: 5_000 });
// Fibonacci-ish: ad-hoc via factor=1.6:
retry(factory, { attempts: 5, delayMs: 100, factor: 1.6 });

No jitter — the delay is deterministic per attempt. If you need jittered retries (recommended for high-concurrency scenarios that might synchronize), wrap the policy:

import { retry, exponentialBackoff } from 'actor-ts';
const policy = exponentialBackoff({ minMs: 200, maxMs: 5_000, randomFactor: 0.2 });
async function jitteredRetry<T>(factory: () => Promise<T>, attempts: number): Promise<T> {
for (let i = 0; i < attempts; i++) {
try { return await factory(); }
catch (e) {
if (i === attempts - 1) throw e;
await new Promise(r => setTimeout(r, policy.delayFor(i)));
}
}
throw new Error('unreachable');
}

Or use BackoffSupervisor if the thing being retried is an actor restart.

class TransientError extends Error {}
class PermanentError extends Error {}
await retry(
() => callExternalAPI(),
{
attempts: 5,
delayMs: 500,
factor: 2,
shouldRetry: (err) => err instanceof TransientError,
},
);

shouldRetry runs after every failed attempt except the last. Return false to short-circuit — the retry loop exits immediately with that error, no more attempts.

Use it to differentiate transient and permanent failures:

  • Transient (network blip, rate limit, lock-contention) → retry.
  • Permanent (validation error, auth failure, 4xx) → don’t retry; the next attempt will fail the same way.
await retry(factory, {
attempts: 3,
delayMs: 500,
onAttempt: (err, attempt) => {
metrics.counter('retry.attempt').inc({ attempt });
log.warn(`attempt ${attempt} failed: ${err.message}`);
},
});

Fires after every failed attempt, including the final one. Use it for retry-aware metrics and logging — counters per attempt, spans for tracing, alerts on “we ran out of retries.”

sleep decides how the delay between attempts is awaited. It defaults to setTimeout, so you never need it in production — override it in tests to take the retry loop off the wall clock:

import { ManualScheduler, retry } from 'actor-ts';
const scheduler = new ManualScheduler();
const attemptTimes: number[] = [];
await retry(
async () => { attemptTimes.push(scheduler.now()); throw new Error('fail'); },
{
attempts: 3,
delayMs: 20,
factor: 2,
maxDelayMs: 30,
sleep: (ms) => new Promise<void>((resolve) => {
scheduler.scheduleOnceFunction(ms, resolve);
scheduler.advance(ms);
}),
},
).catch(() => { /* expected */ });
// Exactly 20ms, then 40ms clamped to maxDelayMs — and instant.
expect(attemptTimes).toEqual([0, 20, 50]);

Every sleep resolves the moment virtual time reaches it, so the schedule is asserted exactly and the test costs no wall-clock time. Measuring real gaps instead is a flake waiting to happen: a 30 ms setTimeout lands anywhere between roughly 19 ms and 200 ms depending on the platform’s timer quantum and machine load, which is wide enough that a capped delay is indistinguishable from an uncapped one.

Same escape hatch as exponentialBackoff’s random — inject the non-determinism, then pin it.

Use caseReach for…
A single Promise-returning call (HTTP fetch, DB query, ask)retry
An actor that fails and should be respawnedBackoffSupervisor
A call protected by short-circuit behaviorCircuitBreaker (often combined with retry inside)

retry is the call-level primitive. BackoffSupervisor is the actor-level primitive. They don’t compete — pick by what you’re protecting.

For an actor that wants to retry its own downstream call, retry inside onReceive is fine:

class MyActor extends Actor<...> {
override async onReceive(message): Promise<void> {
const result = await retry(
() => this.callDownstream(message),
{ attempts: 3, delayMs: 200, factor: 2 },
);
// ...
}
}

Awaiting inside onReceive blocks the actor’s mailbox until the retry completes — same trade-off as any await inside an onReceive. See Ask pattern for when this is a problem.

A circuit breaker is per-dependency state that says “stop trying this thing for a while.” Retry is per-call logic that says “try again right now after a short wait.”

Combine them — retry inside a breaker call:

breaker.call(() => retry(
() => fetch('https://flaky.example'),
{ attempts: 3, delayMs: 100, factor: 2 },
));

The retry handles transient blips during a single call; the breaker tracks the trend across many calls.

The retry API reference covers the full signature.