-
Notifications
You must be signed in to change notification settings - Fork 461
feat(shared): Add cancellation and per-error delay options to retry #9182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e4a53b1
401b36c
f43236b
eb3289e
73ea8b7
e841a9d
860eaa6
05e7a0c
1e1868f
be31b94
93ede24
dd5e622
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,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 = { | ||
|
|
@@ -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++; | ||
| }; | ||
| }; | ||
|
|
@@ -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; | ||
| } | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we handle aborts while
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)); | ||
| } | ||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.