Skip to content
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.
80 changes: 80 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,86 @@ 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('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
78 changes: 59 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,12 @@ 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 abort a callback that is already executing.
*/
signal: AbortSignal;
}>;

const defaultOptions = {
Expand All @@ -63,27 +72,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,22 +122,34 @@ 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;
Expand All @@ -120,9 +160,9 @@ export const retry = async <T>(callback: () => T | Promise<T>, options: RetryOpt
}

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