diff --git a/.changeset/easy-humans-unite.md b/.changeset/easy-humans-unite.md new file mode 100644 index 000000000..248c02c7e --- /dev/null +++ b/.changeset/easy-humans-unite.md @@ -0,0 +1,5 @@ +--- +'@tanstack/pacer': minor +--- + +feat: add maxWait option to AsyncRetryer diff --git a/docs/guides/async-retrying.md b/docs/guides/async-retrying.md index 85c1738ae..317804024 100644 --- a/docs/guides/async-retrying.md +++ b/docs/guides/async-retrying.md @@ -84,7 +84,8 @@ async function fetchData(url: string) { const fetchWithRetry = asyncRetry(fetchData, { maxAttempts: 3, backoff: 'exponential', - baseWait: 1000 + baseWait: 1000, + maxWait: 5000 // Cap wait time at 5 seconds }) // Call it @@ -115,6 +116,7 @@ const retryer = new AsyncRetryer( maxAttempts: 5, backoff: 'exponential', baseWait: 1000, + maxWait: 5000, // Cap wait time at 5 seconds jitter: 0.1, // Add 10% random variation maxExecutionTime: 5000, // Abort individual calls after 5 seconds maxTotalExecutionTime: 30000, // Abort entire operation after 30 seconds @@ -171,6 +173,7 @@ const sharedOptions = asyncRetryerOptions({ maxAttempts: 3, backoff: 'exponential', baseWait: 1000, + maxWait: 5000, // Cap wait time at 5 seconds onSuccess: (result, args, retryer) => console.log('Success') }) @@ -230,6 +233,41 @@ const retryer = new AsyncRetryer(asyncFn, { // Attempt 5: wait 1 second ``` +## Max Wait + +The `maxWait` option caps the maximum wait time between retries, preventing exponential or linear backoff from growing too large. This is particularly useful with exponential backoff, where wait times can quickly become very long: + +```ts +const retryer = new AsyncRetryer(asyncFn, { + backoff: 'exponential', + baseWait: 1000, + maxWait: 5000 // Cap wait time at 5 seconds +}) +// Attempt 1: immediate +// Attempt 2: wait 1 second (1000ms * 2^0) - not capped +// Attempt 3: wait 2 seconds (1000ms * 2^1) - not capped +// Attempt 4: wait 4 seconds (1000ms * 2^2) - not capped +// Attempt 5: wait 5 seconds (would be 8s, but capped at 5s) +// Attempt 6: wait 5 seconds (would be 16s, but capped at 5s) +``` + +Without `maxWait`, exponential backoff can result in very long delays (e.g., 64 seconds, 128 seconds) that may be impractical for your use case. Setting `maxWait` ensures retries continue at a reasonable interval even after many attempts. + +The `maxWait` option also supports dynamic functions: + +```ts +const retryer = new AsyncRetryer(asyncFn, { + backoff: 'exponential', + baseWait: 1000, + maxWait: (retryer) => { + // Increase max wait for critical operations + return retryer.store.state.executionCount > 10 ? 10000 : 5000 + } +}) +``` + +By default, `maxWait` is `Infinity`, meaning there's no cap on wait times. + ## Jitter Jitter adds randomness to retry delays to prevent thundering herd problems, where many clients retry at the same time and overwhelm a recovering service. The `jitter` option accepts a value between 0 and 1, representing the percentage of random variation to apply: @@ -295,6 +333,7 @@ const retryer = new AsyncRetryer(asyncFn, { maxAttempts: 5, backoff: 'exponential', baseWait: 1000, + maxWait: 5000, // Cap wait time between retries maxExecutionTime: 5000, // Individual call timeout maxTotalExecutionTime: 30000 // Overall operation timeout }) @@ -459,6 +498,20 @@ const retryer = new AsyncRetryer(asyncFn, { }) ``` +### Dynamic Max Wait + +```ts +const retryer = new AsyncRetryer(asyncFn, { + backoff: 'exponential', + baseWait: 1000, + maxWait: (retryer) => { + // Increase max wait cap for critical operations + const errorCount = retryer.store.state.executionCount + return errorCount > 10 ? 10000 : 5000 + } +}) +``` + ### Enabling/Disabling ```ts diff --git a/docs/reference/classes/AsyncRetryer.md b/docs/reference/classes/AsyncRetryer.md index e997a1482..af034467c 100644 --- a/docs/reference/classes/AsyncRetryer.md +++ b/docs/reference/classes/AsyncRetryer.md @@ -5,7 +5,7 @@ title: AsyncRetryer # Class: AsyncRetryer\ -Defined in: [async-retryer.ts:288](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L288) +Defined in: [async-retryer.ts:296](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L296) Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies, attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably @@ -22,6 +22,8 @@ by automatically retrying them according to your chosen policy. - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`). Set to a value between 0-1 to apply that percentage of random variation to each delay. +- **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`). + Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s). - **Timeout Controls**: Set limits on execution time to prevent hanging operations: - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`) - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`) @@ -62,7 +64,7 @@ Callbacks for lifecycle management: ## Usage - Use for async operations that may fail transiently and benefit from retrying. -- Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior. +- Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior. - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations. - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects. - Call `abort()` to cancel ongoing execution and pending retries. @@ -113,7 +115,7 @@ The async function type to be retried. new AsyncRetryer(fn, initialOptions): AsyncRetryer; ``` -Defined in: [async-retryer.ts:301](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L301) +Defined in: [async-retryer.ts:309](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L309) Creates a new AsyncRetryer instance @@ -143,7 +145,7 @@ Configuration options for the retryer fn: TFn; ``` -Defined in: [async-retryer.ts:302](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L302) +Defined in: [async-retryer.ts:310](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L310) The async function to retry @@ -155,7 +157,7 @@ The async function to retry key: string | undefined; ``` -Defined in: [async-retryer.ts:292](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L292) +Defined in: [async-retryer.ts:300](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L300) *** @@ -175,7 +177,7 @@ options: AsyncRetryerOptions & Omit>, | "onTotalExecutionTimeout">; ``` -Defined in: [async-retryer.ts:293](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L293) +Defined in: [async-retryer.ts:301](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L301) *** @@ -185,7 +187,7 @@ Defined in: [async-retryer.ts:293](https://github.com/TanStack/pacer/blob/main/p readonly store: Store>>; ``` -Defined in: [async-retryer.ts:289](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L289) +Defined in: [async-retryer.ts:297](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L297) ## Methods @@ -195,7 +197,7 @@ Defined in: [async-retryer.ts:289](https://github.com/TanStack/pacer/blob/main/p abort(reason): void; ``` -Defined in: [async-retryer.ts:605](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L605) +Defined in: [async-retryer.ts:619](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L619) Cancels the current execution and any pending retries @@ -219,7 +221,7 @@ The reason for the abort (defaults to 'manual') execute(...args): Promise> | undefined>; ``` -Defined in: [async-retryer.ts:412](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L412) +Defined in: [async-retryer.ts:426](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L426) Executes the function with retry logic @@ -249,7 +251,7 @@ The last error if throwOnError is true and all retries fail getAbortSignal(): AbortSignal | null; ``` -Defined in: [async-retryer.ts:597](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L597) +Defined in: [async-retryer.ts:611](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L611) Returns the current AbortSignal for the executing operation. Use this signal in your async function to make it cancellable. @@ -282,7 +284,7 @@ retryer.abort() reset(): void; ``` -Defined in: [async-retryer.ts:625](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L625) +Defined in: [async-retryer.ts:639](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L639) Resets the retryer to its initial state @@ -298,7 +300,7 @@ Resets the retryer to its initial state setOptions(newOptions): void; ``` -Defined in: [async-retryer.ts:328](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L328) +Defined in: [async-retryer.ts:336](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L336) Updates the retryer options diff --git a/docs/reference/functions/asyncRetry.md b/docs/reference/functions/asyncRetry.md index 98373017e..87a29a57a 100644 --- a/docs/reference/functions/asyncRetry.md +++ b/docs/reference/functions/asyncRetry.md @@ -9,7 +9,7 @@ title: asyncRetry function asyncRetry(fn, initialOptions): (...args) => Promise> | undefined>; ``` -Defined in: [async-retryer.ts:660](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L660) +Defined in: [async-retryer.ts:674](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L674) Creates a retry-enabled version of an async function. This is a convenience wrapper around the AsyncRetryer class that returns the execute method. diff --git a/docs/reference/functions/asyncRetryerOptions.md b/docs/reference/functions/asyncRetryerOptions.md index 7924cf176..b9b602649 100644 --- a/docs/reference/functions/asyncRetryerOptions.md +++ b/docs/reference/functions/asyncRetryerOptions.md @@ -9,7 +9,7 @@ title: asyncRetryerOptions function asyncRetryerOptions(options): TOptions; ``` -Defined in: [async-retryer.ts:164](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L164) +Defined in: [async-retryer.ts:169](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L169) Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances. diff --git a/docs/reference/interfaces/AsyncRetryerOptions.md b/docs/reference/interfaces/AsyncRetryerOptions.md index 80c3899ff..e89aad1cc 100644 --- a/docs/reference/interfaces/AsyncRetryerOptions.md +++ b/docs/reference/interfaces/AsyncRetryerOptions.md @@ -169,13 +169,31 @@ Infinity *** +### maxWait? + +```ts +optional maxWait: number | (retryer) => number; +``` + +Defined in: [async-retryer.ts:112](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L112) + +Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time + +#### Default + +```ts +Infinity +``` + +*** + ### onAbort()? ```ts optional onAbort: (reason, retryer) => void; ``` -Defined in: [async-retryer.ts:111](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L111) +Defined in: [async-retryer.ts:116](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L116) Callback invoked when the execution is aborted (manually or due to timeouts) @@ -201,7 +219,7 @@ Callback invoked when the execution is aborted (manually or due to timeouts) optional onError: (error, args, retryer) => void; ``` -Defined in: [async-retryer.ts:118](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L118) +Defined in: [async-retryer.ts:123](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L123) Callback invoked when any error occurs during execution (including retries) @@ -231,7 +249,7 @@ Callback invoked when any error occurs during execution (including retries) optional onExecutionTimeout: (retryer) => void; ``` -Defined in: [async-retryer.ts:146](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L146) +Defined in: [async-retryer.ts:131](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L131) Callback invoked when a single execution attempt times out (maxExecutionTime exceeded) @@ -253,7 +271,7 @@ Callback invoked when a single execution attempt times out (maxExecutionTime exc optional onLastError: (error, retryer) => void; ``` -Defined in: [async-retryer.ts:126](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L126) +Defined in: [async-retryer.ts:135](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L135) Callback invoked when the final error occurs after all retries are exhausted @@ -279,7 +297,7 @@ Callback invoked when the final error occurs after all retries are exhausted optional onRetry: (attempt, error, retryer) => void; ``` -Defined in: [async-retryer.ts:130](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L130) +Defined in: [async-retryer.ts:139](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L139) Callback invoked before each retry attempt @@ -309,7 +327,7 @@ Callback invoked before each retry attempt optional onSettled: (args, retryer) => void; ``` -Defined in: [async-retryer.ts:134](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L134) +Defined in: [async-retryer.ts:143](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L143) Callback invoked after execution completes (success or failure) of each attempt @@ -335,7 +353,7 @@ Callback invoked after execution completes (success or failure) of each attempt optional onSuccess: (result, args, retryer) => void; ``` -Defined in: [async-retryer.ts:138](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L138) +Defined in: [async-retryer.ts:147](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L147) Callback invoked when execution succeeds @@ -365,7 +383,7 @@ Callback invoked when execution succeeds optional onTotalExecutionTimeout: (retryer) => void; ``` -Defined in: [async-retryer.ts:150](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L150) +Defined in: [async-retryer.ts:155](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L155) Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded) @@ -387,7 +405,7 @@ Callback invoked when the total execution time times out (maxTotalExecutionTime optional throwOnError: boolean | "last"; ``` -Defined in: [async-retryer.ts:158](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L158) +Defined in: [async-retryer.ts:163](https://github.com/TanStack/pacer/blob/main/packages/pacer/src/async-retryer.ts#L163) Controls when errors are thrown: - 'last': Only throw the final error after all retries are exhausted diff --git a/packages/pacer/src/async-retryer.ts b/packages/pacer/src/async-retryer.ts index b188a76c9..fa12d3dd7 100644 --- a/packages/pacer/src/async-retryer.ts +++ b/packages/pacer/src/async-retryer.ts @@ -105,6 +105,11 @@ export interface AsyncRetryerOptions { * @default Infinity */ maxTotalExecutionTime?: number + /** + * Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time + * @default Infinity + */ + maxWait?: number | ((retryer: AsyncRetryer) => number) /** * Callback invoked when the execution is aborted (manually or due to timeouts) */ @@ -120,6 +125,10 @@ export interface AsyncRetryerOptions { args: Parameters, retryer: AsyncRetryer, ) => void + /** + * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded) + */ + onExecutionTimeout?: (retryer: AsyncRetryer) => void /** * Callback invoked when the final error occurs after all retries are exhausted */ @@ -140,10 +149,6 @@ export interface AsyncRetryerOptions { args: Parameters, retryer: AsyncRetryer, ) => void - /** - * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded) - */ - onExecutionTimeout?: (retryer: AsyncRetryer) => void /** * Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded) */ @@ -185,6 +190,7 @@ const defaultOptions: Omit< > = { backoff: 'exponential', baseWait: 1000, + maxWait: Infinity, enabled: true, jitter: 0, maxAttempts: 3, @@ -209,6 +215,8 @@ const defaultOptions: Omit< * - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt * - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`). * Set to a value between 0-1 to apply that percentage of random variation to each delay. + * - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`). + * Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s). * - **Timeout Controls**: Set limits on execution time to prevent hanging operations: * - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`) * - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`) @@ -249,7 +257,7 @@ const defaultOptions: Omit< * ## Usage * * - Use for async operations that may fail transiently and benefit from retrying. - * - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior. + * - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior. * - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations. * - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects. * - Call `abort()` to cancel ongoing execution and pending retries. @@ -362,6 +370,10 @@ export class AsyncRetryer { return parseFunctionOrValue(this.options.baseWait, this) } + #getMaxWait = (): number => { + return parseFunctionOrValue(this.options.maxWait, this) + } + #calculateJitter = (waitTime: number): number => { const jitterAmount = this.options.jitter if (jitterAmount <= 0) return 0 @@ -399,6 +411,8 @@ export class AsyncRetryer { break } + waitTime = Math.min(waitTime, this.#getMaxWait()) + const jitter = this.#calculateJitter(waitTime) return Math.max(0, waitTime + jitter) } diff --git a/packages/pacer/tests/async-retryer.test.ts b/packages/pacer/tests/async-retryer.test.ts index b8acde82d..448ea9194 100644 --- a/packages/pacer/tests/async-retryer.test.ts +++ b/packages/pacer/tests/async-retryer.test.ts @@ -13,6 +13,7 @@ describe('AsyncRetryer', () => { expect(retryer.options.backoff).toBe('exponential') expect(retryer.options.baseWait).toBe(1000) + expect(retryer.options.maxWait).toBe(Infinity) expect(retryer.options.enabled).toBe(true) expect(retryer.options.maxAttempts).toBe(3) expect(retryer.options.maxExecutionTime).toBe(Infinity) @@ -247,6 +248,68 @@ describe('AsyncRetryer', () => { await executePromise expect(mockFn).toHaveBeenCalledTimes(3) }) + + it('should cap wait time at maxWait', async () => { + const mockFn = vi.fn().mockRejectedValue(new Error('Failure')) + const retryer = new AsyncRetryer(mockFn, { + maxAttempts: 4, + baseWait: 100, + maxWait: 200, + backoff: 'exponential', + throwOnError: false, + }) + + const executePromise = retryer.execute() + + // First retry: 100ms (100 * 2^0) - not capped + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(100) + + // Second retry: 200ms (100 * 2^1) - not capped + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(200) + + // Third retry: would be 400ms (100 * 2^2) but capped at 200ms + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(200) + + await executePromise + expect(mockFn).toHaveBeenCalledTimes(4) + }) + + it('should have Infinity as default maxWait', () => { + const mockFn = vi.fn().mockResolvedValue('success') + const retryer = new AsyncRetryer(mockFn) + + expect(retryer.options.maxWait).toBe(Infinity) + }) + + it('should support function-based maxWait', async () => { + const mockFn = vi.fn().mockRejectedValue(new Error('Failure')) + const maxWaitFn = vi.fn().mockReturnValue(150) + const retryer = new AsyncRetryer(mockFn, { + maxAttempts: 3, + baseWait: 100, + maxWait: maxWaitFn, + backoff: 'exponential', + throwOnError: false, + }) + + const executePromise = retryer.execute() + + // First retry: 100ms (not capped) + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(100) + + // Second retry: would be 200ms but capped at 150ms + await vi.runOnlyPendingTimersAsync() + vi.advanceTimersByTime(150) + + await executePromise + + expect(maxWaitFn).toHaveBeenCalledWith(retryer) + expect(mockFn).toHaveBeenCalledTimes(3) + }) }) describe('Error Handling', () => {