From a73ff944a013fabdceda4987141ba0215ac6c44c Mon Sep 17 00:00:00 2001 From: lionel-rowe Date: Fri, 22 Nov 2024 11:04:32 +0800 Subject: [PATCH] feat(async/unstable): add `isRetriable` option to `retry` (#6197) --- async/deno.json | 1 + async/retry_test.ts | 6 +- async/unstable_retry.ts | 165 +++++++++++++++++++++++++++++++++++ async/unstable_retry_test.ts | 47 ++++++++++ 4 files changed, 216 insertions(+), 3 deletions(-) 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 2d60714e09d3..5378518c32e6 100644 --- a/async/deno.json +++ b/async/deno.json @@ -11,6 +11,7 @@ "./unstable-mux-async-iterator": "./unstable_mux_async_iterator.ts", "./pool": "./pool.ts", "./retry": "./retry.ts", + "./unstable-retry": "./unstable_retry.ts", "./tee": "./tee.ts", "./unstable-throttle": "./unstable_throttle.ts" } diff --git a/async/retry_test.ts b/async/retry_test.ts index d3e217d45bd4..9f130b67c995 100644 --- a/async/retry_test.ts +++ b/async/retry_test.ts @@ -18,7 +18,7 @@ function generateErroringFunction(errorsBeforeSucceeds: number) { Deno.test("retry()", async () => { const threeErrors = generateErroringFunction(3); const result = await retry(threeErrors, { - minTimeout: 100, + minTimeout: 1, }); assertEquals(result, 3); }); @@ -27,7 +27,7 @@ Deno.test("retry() fails after five errors by default", async () => { const fiveErrors = generateErroringFunction(5); await assertRejects(() => retry(fiveErrors, { - minTimeout: 100, + minTimeout: 1, }) ); }); @@ -38,7 +38,7 @@ Deno.test("retry() fails after five errors when undefined is passed", async () = // @ts-expect-error: explicitly giving undefined retry(fiveErrors, { maxAttempts: undefined, - minTimeout: 100, + minTimeout: 1, }) ); }); diff --git a/async/unstable_retry.ts b/async/unstable_retry.ts new file mode 100644 index 000000000000..0dc80c86ef69 --- /dev/null +++ b/async/unstable_retry.ts @@ -0,0 +1,165 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +// This module is browser compatible. + +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; +} + +/** + * 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/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/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, + * }); + * ``` + * + * @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. + */ +export async function retry( + fn: (() => Promise) | (() => T), + options?: RetryOptions, +): Promise { + const { + multiplier = 2, + maxTimeout = 60000, + maxAttempts = 5, + minTimeout = 1000, + jitter = 1, + isRetriable = () => true, + } = options ?? {}; + + if (maxTimeout <= 0) { + throw new TypeError( + `Cannot retry as 'maxTimeout' must be positive: current value is ${maxTimeout}`, + ); + } + if (minTimeout > maxTimeout) { + throw new TypeError( + `Cannot retry as 'minTimeout' must be <= 'maxTimeout': current values 'minTimeout=${minTimeout}', 'maxTimeout=${maxTimeout}'`, + ); + } + if (jitter > 1) { + throw new TypeError( + `Cannot retry as 'jitter' must be <= 1: current value is ${jitter}`, + ); + } + + let attempt = 0; + while (true) { + try { + return await fn(); + } catch (error) { + if (!isRetriable(error)) { + throw error; + } + + if (attempt + 1 >= maxAttempts) { + throw new RetryError(error, maxAttempts); + } + + const timeout = exponentialBackoffWithJitter( + maxTimeout, + minTimeout, + attempt, + multiplier, + jitter, + ); + await new Promise((r) => setTimeout(r, timeout)); + } + attempt++; + } +} diff --git a/async/unstable_retry_test.ts b/async/unstable_retry_test.ts new file mode 100644 index 000000000000..fdfbaa7e24f2 --- /dev/null +++ b/async/unstable_retry_test.ts @@ -0,0 +1,47 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. +import { assertEquals, assertRejects } from "@std/assert"; +import { retry } from "./unstable_retry.ts"; +import { RetryError } from "./retry.ts"; + +Deno.test("retry() only retries errors that are retriable with `isRetriable` option", async () => { + class HttpError extends Error { + status: number; + constructor(status: number) { + super(); + this.status = status; + } + } + + const isRetriable = (err: unknown) => + err instanceof HttpError && (err.status === 429 || err.status >= 500); + + const options = { + minTimeout: 1, + isRetriable, + }; + + let numCalls: number; + + numCalls = 0; + await assertRejects(() => + retry(() => { + numCalls++; + throw new HttpError(400); + }, options), HttpError); + assertEquals(numCalls, 1); + + numCalls = 0; + await assertRejects(() => + retry(() => { + numCalls++; + throw new HttpError(500); + }, options), RetryError); + assertEquals(numCalls, 5); + + numCalls = 0; + await assertRejects(() => + retry(() => { + throw new HttpError(++numCalls === 3 ? 400 : 500); + }, options), HttpError); + assertEquals(numCalls, 3); +});