Retry & backoff
Real work is flaky — a network blip, a transient lock, an occasional out-of-memory. hurried lets you retry any task with one option, on every primitive, with exponential backoff built in. No p-retry, no hand-rolled loops.
import { Thread } from 'hurried';
const t = Thread.fromFunction(fetchAndParse);
// Up to 3 extra attempts after the first.
await t.run(url, { retry: 3 });
retry: 3 means 3 retries — up to four total attempts. The shorthand uses sensible defaults (immediate retry, no delay). Pass an object to shape the backoff:
await t.run(url, {
retry: {
retries: 5,
minDelay: 100, // ms before the first retry
factor: 2, // exponential: 100, 200, 400, 800, …
maxDelay: 5_000, // cap any single delay
jitter: true, // randomize within [0, delay] to avoid thundering herds
},
});
Where it works
retry lives on the call/option object for every entry point:
await thread.run(arg, { retry: 3 });
await pool.run(arg, { retry: { retries: 2, minDelay: 50 } });
await mapParallel(items, task, { retry: 3, concurrency: 8 });
await parallel(tasks, { retry: 2 });
for await (const r of pool.stream(items, { retry: 3 })) { /* … */ }
for await (const r of mapParallelStream(items, task, { retry: 3 })) { /* … */ }
For a Pool, each retry re-queues the task — so a retry can land on a different, healthy worker, not the one that just struggled. The per-call timeout applies to each attempt, not the whole sequence.
retry can't be combined with transferList. A transferred object (e.g. an ArrayBuffer) is detached after the first attempt, so it can't be re-sent — that combination is rejected up front.
What gets retried
By default every error is retried except cancellation and teardown:
TaskAbortedError(you aborted) andTerminatedError(the worker is gone) are never retried — retrying them is pointless.- Everything else —
TaskError(the task threw),TaskTimeoutError— is retried until the budget runs out.
Narrow or widen that with shouldRetry:
await pool.run(arg, {
retry: {
retries: 4,
shouldRetry: (error, attempt) =>
error instanceof TaskError && /ECONNRESET/.test(String(error.cause)),
},
});
Observe each retry with onRetry (great for logging/metrics):
await thread.run(arg, {
retry: { retries: 3, onRetry: (err, attempt) => log.warn(`retry ${attempt}`, err) },
});
Cancellation wins
A retry sequence respects your AbortSignal — including during a backoff delay. Abort and the whole sequence rejects promptly with TaskAbortedError; no further attempts are scheduled.
const controller = new AbortController();
const p = pool.run(arg, {
retry: { retries: 10, minDelay: 1_000 },
signal: controller.signal,
});
controller.abort(); // rejects now, even mid-backoff
RetryOptions
type RetryInput = number | RetryOptions;
interface RetryOptions {
retries?: number; // extra attempts after the first (default 0)
minDelay?: number; // ms before the first retry (default 0)
maxDelay?: number; // cap on any single delay (default unbounded)
factor?: number; // exponential multiplier (default 2)
jitter?: boolean; // full jitter in [0, delay] (default false)
shouldRetry?: (error: unknown, attempt: number) => boolean;
onRetry?: (error: unknown, attempt: number) => void;
}