diff --git a/async/deno.json b/async/deno.json index bcceeac9a110..aa79a3c77de8 100644 --- a/async/deno.json +++ b/async/deno.json @@ -1,6 +1,6 @@ { "name": "@std/async", - "version": "1.0.16", + "version": "1.1.0", "exports": { ".": "./mod.ts", "./abortable": "./abortable.ts", diff --git a/async/retry.ts b/async/retry.ts index 7579b3800142..fa89e1de03e2 100644 --- a/async/retry.ts +++ b/async/retry.ts @@ -61,6 +61,15 @@ export interface RetryOptions { * @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; } /** @@ -112,10 +121,35 @@ export interface RetryOptions { * }); * ``` * + * @example Only retry on specific error types + * ```ts no-assert + * import { retry } from "@std/async/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), + * }); + * ``` + * * @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 `isRetriable` returns `false` for an error, throws that error immediately. */ export async function retry( fn: (() => Promise) | (() => T), @@ -127,6 +161,7 @@ export async function retry( maxAttempts = 5, minTimeout = 1000, jitter = 1, + isRetriable = () => true, } = options ?? {}; if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { @@ -165,6 +200,10 @@ export async function retry( try { return await fn(); } catch (error) { + if (!isRetriable(error)) { + throw error; + } + if (attempt + 1 >= maxAttempts) { throw new RetryError(error, maxAttempts); } diff --git a/async/retry_test.ts b/async/retry_test.ts index c705890d5a6d..034817c2d19b 100644 --- a/async/retry_test.ts +++ b/async/retry_test.ts @@ -336,3 +336,46 @@ Deno.test("retry() caps backoff at maxTimeout", async () => { await assertRejects(() => promise, RetryError); }); + +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); +}); diff --git a/import_map.json b/import_map.json index 6f68dd6653ce..099db0653f01 100644 --- a/import_map.json +++ b/import_map.json @@ -5,9 +5,8 @@ "npm:/typescript": "npm:typescript@5.8.2", "automation/": "https://raw.githubusercontent.com/denoland/automation/0.10.0/", "graphviz": "npm:node-graphviz@^0.1.1", - "@std/assert": "jsr:@std/assert@^1.0.16", - "@std/async": "jsr:@std/async@^1.0.16", + "@std/async": "jsr:@std/async@^1.1.0", "@std/bytes": "jsr:@std/bytes@^1.0.6", "@std/cache": "jsr:@std/cache@^0.2.1", "@std/cbor": "jsr:@std/cbor@^0.1.9",