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
5 changes: 5 additions & 0 deletions .changeset/shared-retry-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/shared': patch
---

The `retry` helper now supports cancellation and error-dependent backoff. Passing an `AbortSignal` via the new `signal` option cancels retrying, immediately interrupting any pending backoff delay, and `initialDelay` now also accepts a function deriving the backoff base delay from the error that triggered the retry.
103 changes: 103 additions & 0 deletions packages/shared/src/__tests__/retry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,109 @@ describe('retry', () => {
expect(attempts).toBe(4);
});

test('rejects without calling the callback when the signal is already aborted', async () => {
const callback = vi.fn();
const controller = new AbortController();
controller.abort(new Error('cancelled'));

await expect(retry(callback, { signal: controller.signal })).rejects.toThrow('cancelled');
expect(callback).not.toHaveBeenCalled();
});

test('aborting during a delay rejects immediately and stops retrying', async () => {
let attempts = 0;
const controller = new AbortController();

const result = retry(
() => {
attempts++;
throw new Error('failed');
},
{
initialDelay: 10_000,
jitter: false,
shouldRetry: () => true,
signal: controller.signal,
},
);
const assertion = expect(result).rejects.toThrow('cancelled');

await vi.advanceTimersByTimeAsync(0);
expect(attempts).toBe(1);

controller.abort(new Error('cancelled'));
await assertion;

await vi.advanceTimersByTimeAsync(60_000);
expect(attempts).toBe(1);
});

test('rejects with the abort reason when the signal aborts while the callback is running', async () => {
const controller = new AbortController();
const onBeforeRetry = vi.fn();
let attempts = 0;

const result = retry(
() => {
attempts++;
controller.abort(new Error('cancelled'));
throw new Error('failed');
},
{ shouldRetry: () => true, signal: controller.signal, onBeforeRetry },
);

await expect(result).rejects.toThrow('cancelled');
expect(attempts).toBe(1);
expect(onBeforeRetry).not.toHaveBeenCalled();
});

test('the abort reason wins over an onBeforeRetry rejection', async () => {
const controller = new AbortController();
let attempts = 0;

const result = retry(
() => {
attempts++;
throw new Error('failed');
},
{
shouldRetry: () => true,
signal: controller.signal,
onBeforeRetry: () => {
controller.abort(new Error('cancelled'));
throw new Error('hook failed');
},
},
);

await expect(result).rejects.toThrow('cancelled');
expect(attempts).toBe(1);
});

test('a functional initialDelay derives the backoff base from the error', async () => {
let attempts = 0;
retry(
() => {
attempts++;
throw attempts === 1 ? new Error('fast') : new Error('slow');
},
{
factor: 2,
jitter: false,
shouldRetry: (_, count) => count <= 2,
initialDelay: error => ((error as Error).message === 'fast' ? 100 : 1000),
},
).catch(() => {});

expect(attempts).toBe(1);
// First retry: base 100 * 2^0
await vi.advanceTimersByTimeAsync(100);
expect(attempts).toBe(2);
// Second retry: base 1000 * 2^1
await vi.advanceTimersByTimeAsync(2_000);
expect(attempts).toBe(3);
});

test('applies jitter by default', async () => {
let attempts = 0;

Expand Down
86 changes: 67 additions & 19 deletions packages/shared/src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ type Milliseconds = number;

type RetryOptions = Partial<{
/**
* The initial delay before the first retry.
* The base delay of the exponential backoff: either milliseconds, or a function
* deriving them from the error that triggered the retry and the iteration number,
* starting at 1 for the first retry.
* The exponential factor, max delay, and jitter still apply.
*
* @default 125
*/
initialDelay: Milliseconds;
initialDelay: Milliseconds | ((error: unknown, iteration: number) => Milliseconds);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* The maximum delay between retries.
* The delay between retries will never exceed this value.
Expand Down Expand Up @@ -50,6 +53,14 @@ type RetryOptions = Partial<{
* This can be used to modify request parameters, add headers, etc.
*/
onBeforeRetry?: (iteration: number) => void | Promise<void>;
/**
* An AbortSignal that cancels retrying.
* Aborting rejects the returned promise with the signal's abort reason, immediately
* interrupting any pending delay. It does not interrupt a callback or onBeforeRetry
* hook that is already running, but once it settles the abort reason takes
* precedence over its result or error.
*/
signal: AbortSignal;
}>;

const defaultOptions = {
Expand All @@ -63,27 +74,46 @@ const defaultOptions = {

const RETRY_IMMEDIATELY_DELAY = 100;

const sleep = async (ms: Milliseconds) => new Promise(s => setTimeout(s, ms));
const abortReason = (signal: AbortSignal): Error => signal.reason ?? new Error('The operation was aborted');

const sleep = async (ms: Milliseconds, signal?: AbortSignal) =>
new Promise<void>((resolve, reject) => {
if (!signal) {
setTimeout(resolve, ms);
return;
}
if (signal.aborted) {
reject(abortReason(signal));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(abortReason(signal));
};
const timer = setTimeout(() => {
signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal.addEventListener('abort', onAbort, { once: true });
});

const applyJitter = (delay: Milliseconds, jitter: boolean) => {
return jitter ? delay * (1 + Math.random()) : delay;
};

const createExponentialDelayAsyncFn = (
opts: Required<Pick<RetryOptions, 'initialDelay' | 'maxDelayBetweenRetries' | 'factor' | 'jitter'>>,
opts: Required<Pick<RetryOptions, 'maxDelayBetweenRetries' | 'factor' | 'jitter'>> & Pick<RetryOptions, 'signal'>,
) => {
let timesCalled = 0;

const calculateDelayInMs = () => {
const constant = opts.initialDelay;
const base = opts.factor;
let delay = constant * Math.pow(base, timesCalled);
const calculateDelayInMs = (base: Milliseconds) => {
let delay = base * Math.pow(opts.factor, timesCalled);
delay = applyJitter(delay, opts.jitter);
return Math.min(opts.maxDelayBetweenRetries || delay, delay);
};

return async (): Promise<void> => {
await sleep(calculateDelayInMs());
return async (base: Milliseconds): Promise<void> => {
await sleep(calculateDelayInMs(base), opts.signal);
timesCalled++;
};
};
Expand All @@ -94,35 +124,53 @@ const createExponentialDelayAsyncFn = (
*/
export const retry = async <T>(callback: () => T | Promise<T>, options: RetryOptions = {}): Promise<T> => {
let iterations = 0;
const { shouldRetry, initialDelay, maxDelayBetweenRetries, factor, retryImmediately, jitter, onBeforeRetry } = {
...defaultOptions,
...options,
};
const { shouldRetry, initialDelay, maxDelayBetweenRetries, factor, retryImmediately, jitter, onBeforeRetry, signal } =
{
...defaultOptions,
...options,
};

const resolveInitialDelay = typeof initialDelay === 'function' ? initialDelay : () => initialDelay;
const delay = createExponentialDelayAsyncFn({
initialDelay,
maxDelayBetweenRetries,
factor,
jitter,
signal,
});

while (true) {
if (signal?.aborted) {
throw abortReason(signal);
}

try {
return await callback();
const result = await callback();
if (signal?.aborted) {
throw abortReason(signal);
}
return result;
} catch (e) {
if (signal?.aborted) {
throw abortReason(signal);
}

iterations++;
if (!shouldRetry(e, iterations)) {
throw e;
}

if (onBeforeRetry) {
await onBeforeRetry(iterations);
try {
await onBeforeRetry(iterations);
} catch (hookError) {
throw signal?.aborted ? abortReason(signal) : hookError;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we handle aborts while onBeforeRetry is running too? Right now, if the signal aborts while an async hook is pending, retry() waits for the hook to finish. If the hook rejects, its error also wins over the abort reason. Could we make this path follow the same cancellation behavior as the retry delay?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking this out! Pushed a fix (ref) for the error precedence. If the hook rejects after an abort, the abort reason now wins, same as the callback path

I left the pending-hook behavior as is. We don't interrupt user code, same as the callback, and the abort still wins as soon as the hook settles. I also updated the JSDoc to document this.


if (retryImmediately && iterations === 1) {
await sleep(applyJitter(RETRY_IMMEDIATELY_DELAY, jitter));
await sleep(applyJitter(RETRY_IMMEDIATELY_DELAY, jitter), signal);
} else {
await delay();
await delay(resolveInitialDelay(e, iterations));
}
}
}
Expand Down
Loading