From e4a53b1c0934c49667f61179e3a69bcec0c065cf Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 16 Jul 2026 11:32:03 -0700 Subject: [PATCH 1/6] feat(shared): add cancellation and per-error delay options to retry Adds an optional signal (AbortSignal) option so callers can cancel retrying promptly, and allows initialDelay to be a function deriving the backoff base delay from the error that triggered the retry. --- .changeset/shared-retry-cancellation.md | 5 ++ packages/shared/src/__tests__/retry.spec.ts | 61 +++++++++++++++++++ packages/shared/src/retry.ts | 67 +++++++++++++++------ 3 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 .changeset/shared-retry-cancellation.md diff --git a/.changeset/shared-retry-cancellation.md b/.changeset/shared-retry-cancellation.md new file mode 100644 index 00000000000..6ab9ddbb2db --- /dev/null +++ b/.changeset/shared-retry-cancellation.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': minor +--- + +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..0f1b0a0e57c 100644 --- a/packages/shared/src/__tests__/retry.spec.ts +++ b/packages/shared/src/__tests__/retry.spec.ts @@ -172,6 +172,67 @@ 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('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..0aaf6528f19 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -2,11 +2,13 @@ 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. + * 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 +52,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 +71,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,19 +121,25 @@ 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(); } catch (e) { @@ -120,9 +153,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)); } } } From 401b36cd47225fa7b18baf32378052687d95e0bf Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 16 Jul 2026 12:18:22 -0700 Subject: [PATCH 2/6] Update retry helper for cancellation and backoff support The `retry` helper now supports cancellation and error-dependent backoff. Added `signal` option for cancellation and modified `initialDelay` to accept a function for backoff base delay. --- .changeset/shared-retry-cancellation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/shared-retry-cancellation.md b/.changeset/shared-retry-cancellation.md index 6ab9ddbb2db..31e6be529a7 100644 --- a/.changeset/shared-retry-cancellation.md +++ b/.changeset/shared-retry-cancellation.md @@ -1,5 +1,5 @@ --- -'@clerk/shared': minor +'@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. From eb3289e03512095cd03795488d8a1262e198a5d1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 16 Jul 2026 13:28:26 -0700 Subject: [PATCH 3/6] fix(shared): reject with abort reason when callback rejects after cancellation --- packages/shared/src/__tests__/retry.spec.ts | 19 +++++++++++++++++++ packages/shared/src/retry.ts | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/__tests__/retry.spec.ts b/packages/shared/src/__tests__/retry.spec.ts index 0f1b0a0e57c..e5db43c98c8 100644 --- a/packages/shared/src/__tests__/retry.spec.ts +++ b/packages/shared/src/__tests__/retry.spec.ts @@ -209,6 +209,25 @@ describe('retry', () => { 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( diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index 0aaf6528f19..1065552b58b 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -3,7 +3,8 @@ type Milliseconds = number; type RetryOptions = Partial<{ /** * 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. + * 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 @@ -143,6 +144,10 @@ export const retry = async (callback: () => T | Promise, options: RetryOpt try { return await callback(); } catch (e) { + if (signal?.aborted) { + throw abortReason(signal); + } + iterations++; if (!shouldRetry(e, iterations)) { throw e; From 73ea8b7041d5d31a2e0fdbcfb126b290675a7e24 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Thu, 16 Jul 2026 21:37:14 -0700 Subject: [PATCH 4/6] Update packages/shared/src/retry.ts Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- packages/shared/src/retry.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index 1065552b58b..a18e79903a5 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -142,7 +142,12 @@ export const retry = async (callback: () => T | Promise, options: RetryOpt } try { - return await callback(); + const result = await callback(); + if (signal?.aborted) { + throw abortReason(signal); + } + return result; + } catch (e) { } catch (e) { if (signal?.aborted) { throw abortReason(signal); From 05e7a0c6880cddecd1c4a6938d5b9156641b47b4 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Sat, 18 Jul 2026 15:03:07 -0700 Subject: [PATCH 5/6] Update packages/shared/src/retry.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/shared/src/retry.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index a18e79903a5..89c7b702c28 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -147,7 +147,6 @@ export const retry = async (callback: () => T | Promise, options: RetryOpt throw abortReason(signal); } return result; - } catch (e) { } catch (e) { if (signal?.aborted) { throw abortReason(signal); From 1e1868f451a1739be02d1864796e3fdce7872786 Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Sat, 18 Jul 2026 15:03:25 -0700 Subject: [PATCH 6/6] Update packages/shared/src/retry.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- packages/shared/src/retry.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/shared/src/retry.ts b/packages/shared/src/retry.ts index 89c7b702c28..72c6666e9ae 100644 --- a/packages/shared/src/retry.ts +++ b/packages/shared/src/retry.ts @@ -136,11 +136,9 @@ export const retry = async (callback: () => T | Promise, options: RetryOpt signal, }); - while (true) { - if (signal?.aborted) { - throw abortReason(signal); - } - + if (signal?.aborted) { + throw abortReason(signal); + } try { const result = await callback(); if (signal?.aborted) {