diff --git a/.changeset/shared-retry-cancellation.md b/.changeset/shared-retry-cancellation.md new file mode 100644 index 00000000000..31e6be529a7 --- /dev/null +++ b/.changeset/shared-retry-cancellation.md @@ -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. diff --git a/packages/shared/src/__tests__/retry.spec.ts b/packages/shared/src/__tests__/retry.spec.ts index 781f106716d..e5db43c98c8 100644 --- a/packages/shared/src/__tests__/retry.spec.ts +++ b/packages/shared/src/__tests__/retry.spec.ts @@ -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; diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index 39ccc87556e..72c6666e9ae 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -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); /** * The maximum delay between retries. * The delay between retries will never exceed this value. @@ -50,6 +53,12 @@ type RetryOptions = Partial<{ * This can be used to modify request parameters, add headers, etc. */ onBeforeRetry?: (iteration: number) => void | Promise; + /** + * 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 = { @@ -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((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>, + opts: Required> & Pick, ) => { 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 => { - await sleep(calculateDelayInMs()); + return async (base: Milliseconds): Promise => { + await sleep(calculateDelayInMs(base), opts.signal); timesCalled++; }; }; @@ -94,22 +122,34 @@ const createExponentialDelayAsyncFn = ( */ export const retry = async (callback: () => T | Promise, options: RetryOptions = {}): Promise => { 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; @@ -120,9 +160,9 @@ export const retry = async (callback: () => T | Promise, 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)); } } }