-
Notifications
You must be signed in to change notification settings - Fork 462
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
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,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 = { | ||||||||||||||||||||
|
|
@@ -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++; | ||||||||||||||||||||
| }; | ||||||||||||||||||||
| }; | ||||||||||||||||||||
|
|
@@ -94,22 +122,32 @@ 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); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
Comment on lines
139
to
+143
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. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Recheck cancellation immediately after the callback rejects. If the signal aborts while Proposed fix } catch (e) {
+ if (signal?.aborted) {
+ throw abortReason(signal);
+ }
+
iterations++;Add a regression test where the signal aborts during an active callback and that callback subsequently rejects. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| try { | ||||||||||||||||||||
| return await callback(); | ||||||||||||||||||||
|
Comment on lines
144
to
145
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. 🟡 Medium If
Suggested change
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||
| if (signal?.aborted) { | ||||||||||||||||||||
| throw abortReason(signal); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| iterations++; | ||||||||||||||||||||
| if (!shouldRetry(e, iterations)) { | ||||||||||||||||||||
| throw e; | ||||||||||||||||||||
|
|
@@ -120,9 +158,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)); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Document that
iterationstarts at 1.This new public callback parameter otherwise has ambiguous indexing semantics.
Proposed documentation update
The Docs team may also need to review this generated reference-doc change. As per path instructions, JSDoc on public/reference-facing APIs is customer-facing documentation.
📝 Committable suggestion
🤖 Prompt for AI Agents
Source: Path instructions