Skip to content

Commit

Permalink
feat(async/unstable): add isRetriable option to retry (#6197)
Browse files Browse the repository at this point in the history
  • Loading branch information
lionel-rowe authored Nov 22, 2024
1 parent 93e0cd6 commit a73ff94
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 3 deletions.
1 change: 1 addition & 0 deletions async/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
6 changes: 3 additions & 3 deletions async/retry_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand All @@ -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,
})
);
});
Expand All @@ -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,
})
);
});
Expand Down
165 changes: 165 additions & 0 deletions async/unstable_retry.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
fn: (() => Promise<T>) | (() => T),
options?: RetryOptions,
): Promise<T> {
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++;
}
}
47 changes: 47 additions & 0 deletions async/unstable_retry_test.ts
Original file line number Diff line number Diff line change
@@ -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);
});

0 comments on commit a73ff94

Please sign in to comment.