Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion async/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ export interface RetryOptions {
* @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;
}

/**
Expand Down Expand Up @@ -149,6 +159,7 @@ export interface RetryOptions {
* @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.
*/
export async function retry<T>(
Expand All @@ -162,6 +173,7 @@ export async function retry<T>(
minTimeout = 1000,
jitter = 1,
isRetriable = () => true,
signal,
} = options ?? {};

if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
Expand Down Expand Up @@ -197,6 +209,8 @@ export async function retry<T>(

let attempt = 0;
while (true) {
signal?.throwIfAborted();

try {
return await fn();
} catch (error) {
Expand All @@ -215,7 +229,7 @@ export async function retry<T>(
multiplier,
jitter,
);
await delay(timeout);
await delay(timeout, signal ? { signal } : undefined);
}
attempt++;
}
Expand Down
48 changes: 48 additions & 0 deletions async/retry_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,3 +379,51 @@ Deno.test("retry() only retries errors that are retriable with `isRetriable` opt
}, options), HttpError);
assertEquals(numCalls, 3);
});

Deno.test("retry() aborts during delay when signal is aborted", async () => {
using time = new FakeTime();
const controller = new AbortController();
let attempts = 0;

const promise = retry(() => {
attempts++;
throw new Error("fail");
}, { signal: controller.signal, jitter: 0 });

await time.nextAsync(); // First delay starts (1000ms)
controller.abort("cancelled");

const error = await assertRejects(() => promise);
assertEquals(error, "cancelled");
assertEquals(attempts, 2); // Only 2 attempts, not 5
});

Deno.test("retry() throws immediately if signal is already aborted", async () => {
const controller = new AbortController();
controller.abort("pre-aborted");
let called = false;

const error = await assertRejects(
() =>
retry(() => {
called = true;
return "ok";
}, { signal: controller.signal }),
);
assertEquals(error, "pre-aborted");
assertEquals(called, false); // fn was never called
});

Deno.test("retry() throws AbortError when signal is aborted without reason", async () => {
const controller = new AbortController();
controller.abort(); // No reason = DOMException with name "AbortError"

const error = await assertRejects(
() =>
retry(() => {
throw new Error();
}, { signal: controller.signal }),
DOMException,
);
assertEquals(error.name, "AbortError");
});
Loading