From d9d328c3f5d0ed2bbf68ce9b5eb7a4b3a6e73067 Mon Sep 17 00:00:00 2001 From: Tomas Zijdemans Date: Sun, 23 Aug 2026 20:49:03 +0200 Subject: [PATCH] feat(async/unstable): add onRetry option to `retry()` --- async/deno.json | 1 + async/unstable_retry.ts | 273 +++++++++++++++++++++++++++++++++++ async/unstable_retry_test.ts | 170 ++++++++++++++++++++++ 3 files changed, 444 insertions(+) create mode 100644 async/unstable_retry.ts create mode 100644 async/unstable_retry_test.ts diff --git a/async/deno.json b/async/deno.json index 5c3b3273b56b..b1b94894a6e6 100644 --- a/async/deno.json +++ b/async/deno.json @@ -14,6 +14,7 @@ "./pool": "./pool.ts", "./unstable-pool": "./unstable_pool.ts", "./retry": "./retry.ts", + "./unstable-retry": "./unstable_retry.ts", "./tee": "./tee.ts", "./all-keyed": "./all_keyed.ts", "./unstable-abortable": "./unstable_abortable.ts", diff --git a/async/unstable_retry.ts b/async/unstable_retry.ts new file mode 100644 index 000000000000..0dee2fb29b80 --- /dev/null +++ b/async/unstable_retry.ts @@ -0,0 +1,273 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. +import { delay } from "./delay.ts"; +import { exponentialBackoffWithJitter } from "./_util.ts"; +import { RetryError } from "./retry.ts"; + +/** + * Options for {@linkcode retry}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export interface RetryOptions { + /** + * How much to backoff after each retry. + * + * @default {2} + */ + multiplier?: number; + /** + * The maximum milliseconds between attempts. + * + * @default {60000} + */ + maxTimeout?: number; + /** + * The maximum amount of attempts until failure. + * + * @default {5} + */ + maxAttempts?: number; + /** + * The initial and minimum amount of milliseconds between attempts. + * + * @default {1000} + */ + minTimeout?: number; + /** + * Amount of jitter to introduce to the time between attempts. This is `1` + * for full jitter by default. + * + * @default {1} + */ + jitter?: number; + /** + * Callback to determine if an error or other thrown value is retriable. + * + * @default {() => true} + * + * @param err The thrown error or other value. + * @returns `true` if the error is retriable, `false` otherwise. + */ + isRetriable?: (err: unknown) => boolean; + /** + * An AbortSignal to cancel the retry operation. + * + * If the signal is aborted, the retry will stop and reject with the signal's + * reason. The signal is checked before each attempt and during the delay + * between attempts. + * + * @default {undefined} + */ + signal?: AbortSignal; + /** + * Callback invoked when a retry is about to happen, immediately before + * waiting for the backoff delay. + * + * Called only when another attempt will follow: never for the terminal + * failure that becomes {@linkcode RetryError}, never for errors rejected + * by `isRetriable`, and never when `signal` is already aborted. An abort + * during the backoff wait can still cancel a retry after this callback + * has fired. + * + * The return value is ignored and not awaited. If the callback throws, + * {@linkcode retry} rejects with that error and makes no further attempts. + * + * @default {undefined} + * + * @param error The error or other value thrown by the failed attempt. + * @param attempt The 1-based number of the attempt that just failed, + * ranging from `1` to `maxAttempts - 1`. + * @param delay The number of milliseconds to wait before the next attempt, + * after backoff and jitter have been applied. + */ + onRetry?: (error: unknown, attempt: number, delay: number) => void; +} + +/** + * Calls the given (possibly asynchronous) function up to `maxAttempts` times. + * Retries as long as the given function throws. If the attempts are exhausted, + * throws a {@linkcode RetryError} with `cause` set to the inner exception. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * The backoff is calculated by multiplying `minTimeout` with `multiplier` to the power of the current attempt counter (starting at 0 up to `maxAttempts - 1`). It is capped at `maxTimeout` however. + * How long the actual delay is, depends on `jitter`. + * + * When `jitter` is the default value of `1`, waits between two attempts for a + * randomized amount between 0 and the backoff time. With the default options + * the maximal delay will be `15s = 1s + 2s + 4s + 8s`. If all five attempts + * are exhausted the mean delay will be `9.5s = ½(4s + 15s)`. + * + * When `jitter` is `0`, waits the full backoff time. + * + * @example Example configuration 1 + * ```ts no-assert + * import { retry } from "@std/async/unstable-retry"; + * const req = async () => { + * // some function that throws sometimes + * }; + * + * // Below resolves to the first non-error result of `req` + * const retryPromise = await retry(req, { + * multiplier: 2, + * maxTimeout: 60000, + * maxAttempts: 5, + * minTimeout: 100, + * jitter: 1, + * }); + * ``` + * + * @example Example configuration 2 + * ```ts no-assert + * import { retry } from "@std/async/unstable-retry"; + * const req = async () => { + * // some function that throws sometimes + * }; + * + * // Make sure we wait at least 1 minute, but at most 2 minutes + * const retryPromise = await retry(req, { + * multiplier: 2.34, + * maxTimeout: 80000, + * maxAttempts: 7, + * minTimeout: 1000, + * jitter: 0.5, + * }); + * ``` + * + * @example Only retry on specific error types + * ```ts no-assert + * import { retry } from "@std/async/unstable-retry"; + * + * class HttpError extends Error { + * status: number; + * constructor(status: number) { + * super(`HTTP ${status}`); + * this.status = status; + * } + * } + * + * const req = async () => { + * // some function that throws HttpError + * }; + * + * // Only retry on 429 (rate limit) or 5xx (server) errors + * const retryPromise = await retry(req, { + * isRetriable: (err) => + * err instanceof HttpError && (err.status === 429 || err.status >= 500), + * }); + * ``` + * + * @example Observing retries with `onRetry` + * ```ts + * import { retry } from "@std/async/unstable-retry"; + * import { assertEquals } from "@std/assert"; + * + * let attempts = 0; + * const logs: string[] = []; + * + * const result = await retry(() => { + * attempts++; + * if (attempts < 3) throw new Error("Service unavailable"); + * return "ok"; + * }, { + * minTimeout: 1, + * jitter: 0, + * onRetry(_error, attempt, delay) { + * logs.push(`Attempt ${attempt} failed, retrying in ${delay}ms`); + * }, + * }); + * + * assertEquals(result, "ok"); + * assertEquals(logs, [ + * "Attempt 1 failed, retrying in 1ms", + * "Attempt 2 failed, retrying in 2ms", + * ]); + * ``` + * + * @typeParam T The return type of the function to retry and returned promise. + * @param fn The function to retry. + * @param options Additional options. + * @returns The promise that resolves with the value returned by the function to retry. + * @throws {RetryError} If the function fails after `maxAttempts` attempts. + * @throws If the `signal` is aborted, throws the signal's reason. + * @throws If `isRetriable` returns `false` for an error, throws that error immediately. + * @throws If `onRetry` throws, throws that error without further attempts. + */ +export async function retry( + fn: (() => Promise) | (() => T), + options?: RetryOptions, +): Promise { + const { + multiplier = 2, + maxTimeout = 60000, + maxAttempts = 5, + minTimeout = 1000, + jitter = 1, + isRetriable = () => true, + signal, + onRetry, + } = options ?? {}; + + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new RangeError( + `Cannot retry as 'maxAttempts' must be a positive integer: current value is ${maxAttempts}`, + ); + } + if (!Number.isFinite(multiplier) || multiplier < 1) { + throw new RangeError( + `Cannot retry as 'multiplier' must be a finite number >= 1: current value is ${multiplier}`, + ); + } + if (Number.isNaN(maxTimeout) || maxTimeout <= 0) { + throw new RangeError( + `Cannot retry as 'maxTimeout' must be a positive number: current value is ${maxTimeout}`, + ); + } + if (Number.isNaN(minTimeout) || minTimeout < 0) { + throw new RangeError( + `Cannot retry as 'minTimeout' must be >= 0: current value is ${minTimeout}`, + ); + } + if (minTimeout > maxTimeout) { + throw new RangeError( + `Cannot retry as 'minTimeout' must be <= 'maxTimeout': current values 'minTimeout=${minTimeout}', 'maxTimeout=${maxTimeout}'`, + ); + } + if (Number.isNaN(jitter) || jitter < 0 || jitter > 1) { + throw new RangeError( + `Cannot retry as 'jitter' must be between 0 and 1: current value is ${jitter}`, + ); + } + + let attempt = 0; + while (true) { + signal?.throwIfAborted(); + + try { + return await fn(); + } catch (error) { + if (!isRetriable(error)) { + throw error; + } + + if (attempt + 1 >= maxAttempts) { + throw new RetryError(error, maxAttempts); + } + + // An aborted signal means no retry follows, so onRetry must not fire. + signal?.throwIfAborted(); + + const timeout = exponentialBackoffWithJitter( + maxTimeout, + minTimeout, + attempt, + multiplier, + jitter, + ); + onRetry?.(error, attempt + 1, timeout); + await delay(timeout, signal ? { signal } : undefined); + } + attempt++; + } +} diff --git a/async/unstable_retry_test.ts b/async/unstable_retry_test.ts new file mode 100644 index 000000000000..70e11eb842fc --- /dev/null +++ b/async/unstable_retry_test.ts @@ -0,0 +1,170 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { retry } from "./unstable_retry.ts"; +import { RetryError } from "./retry.ts"; +import { assertEquals, assertRejects } from "@std/assert"; +import { FakeTime } from "@std/testing/time"; + +Deno.test("retry() retries until the function succeeds", async () => { + let attempts = 0; + const result = await retry(() => { + attempts++; + if (attempts < 3) throw new Error("Not yet"); + return "success"; + }, { minTimeout: 1 }); + assertEquals(result, "success"); + assertEquals(attempts, 3); +}); + +Deno.test("retry() calls onRetry once per retry with 1-based attempt numbers", async () => { + const attempts: number[] = []; + await assertRejects( + () => + retry(() => { + throw new Error("Failure"); + }, { + maxAttempts: 4, + minTimeout: 1, + jitter: 0, + onRetry: (_error, attempt, _delay) => { + attempts.push(attempt); + }, + }), + RetryError, + ); + assertEquals(attempts, [1, 2, 3]); +}); + +Deno.test("retry() passes the thrown error to onRetry", async () => { + const thrown = new Error("Specific failure"); + const seen: unknown[] = []; + await assertRejects( + () => + retry(() => { + throw thrown; + }, { + maxAttempts: 2, + minTimeout: 1, + onRetry: (error) => { + seen.push(error); + }, + }), + RetryError, + ); + assertEquals(seen, [thrown]); +}); + +Deno.test("retry() passes the computed backoff delay to onRetry", async () => { + using time = new FakeTime(); + const delays: number[] = []; + const promise = retry(() => { + throw new Error("Failure"); + }, { + jitter: 0, + onRetry: (_error, _attempt, delay) => { + delays.push(delay); + }, + }); + await time.runAllAsync(); + await assertRejects(() => promise, RetryError); + assertEquals(delays, [1000, 2000, 4000, 8000]); +}); + +Deno.test("retry() calls onRetry before the backoff wait begins", async () => { + using time = new FakeTime(); + let called = false; + const promise = retry(() => { + throw new Error("Failure"); + }, { + maxAttempts: 2, + jitter: 0, + onRetry: () => { + called = true; + }, + }); + await time.runMicrotasks(); + assertEquals(called, true); // fired while no fake time has advanced + await time.runAllAsync(); + await assertRejects(() => promise, RetryError); +}); + +Deno.test("retry() does not call onRetry when the first attempt succeeds", async () => { + let calls = 0; + const result = await retry(() => "ok", { + onRetry: () => { + calls++; + }, + }); + assertEquals(result, "ok"); + assertEquals(calls, 0); +}); + +Deno.test("retry() does not call onRetry when isRetriable returns false", async () => { + let calls = 0; + await assertRejects( + () => + retry(() => { + throw new Error("Failure"); + }, { + isRetriable: () => false, + onRetry: () => { + calls++; + }, + }), + Error, + "Failure", + ); + assertEquals(calls, 0); +}); + +Deno.test("retry() does not call onRetry for the terminal failure", async () => { + let calls = 0; + await assertRejects( + () => + retry(() => { + throw new Error("Failure"); + }, { + maxAttempts: 1, + onRetry: () => { + calls++; + }, + }), + RetryError, + ); + assertEquals(calls, 0); +}); + +Deno.test("retry() does not call onRetry when the signal is already aborted", async () => { + const controller = new AbortController(); + let calls = 0; + const error = await assertRejects(() => + retry(() => { + controller.abort("cancelled"); + throw new Error("Failure"); + }, { + signal: controller.signal, + minTimeout: 1, + onRetry: () => { + calls++; + }, + }) + ); + assertEquals(error, "cancelled"); + assertEquals(calls, 0); +}); + +Deno.test("retry() rejects with the error thrown by onRetry and stops retrying", async () => { + const callbackError = new Error("Callback failure"); + let attempts = 0; + const error = await assertRejects(() => + retry(() => { + attempts++; + throw new Error("Failure"); + }, { + onRetry: () => { + throw callbackError; + }, + }) + ); + assertEquals(error, callbackError); + assertEquals(attempts, 1); +});