@tanstack/pacer 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/async-retryer.cjs +10 -4
- package/dist/async-retryer.cjs.map +1 -1
- package/dist/async-retryer.d.cts +12 -5
- package/dist/async-retryer.d.ts +12 -5
- package/dist/async-retryer.js +10 -4
- package/dist/async-retryer.js.map +1 -1
- package/dist/batcher.cjs +4 -4
- package/dist/batcher.cjs.map +1 -1
- package/dist/batcher.js +4 -4
- package/dist/batcher.js.map +1 -1
- package/package.json +1 -1
- package/src/async-retryer.ts +21 -7
package/README.md
CHANGED
|
@@ -86,7 +86,7 @@ A lightweight timing and scheduling library for debouncing, throttling, rate lim
|
|
|
86
86
|
<br />
|
|
87
87
|
|
|
88
88
|
> [!NOTE]
|
|
89
|
-
> You may know **
|
|
89
|
+
> You may know **TanStack Pacer** by our adapter names, too!
|
|
90
90
|
>
|
|
91
91
|
> - [**React Pacer**](https://tanstack.com/pacer/latest/docs/framework/react/react-pacer)
|
|
92
92
|
> - [**Preact Pacer**](https://tanstack.com/pacer/latest/docs/framework/preact/preact-pacer)
|
package/dist/async-retryer.cjs
CHANGED
|
@@ -8,7 +8,7 @@ let _tanstack_store = require("@tanstack/store");
|
|
|
8
8
|
* @returns The default state with all values reset to initial values
|
|
9
9
|
*/
|
|
10
10
|
function getDefaultAsyncRetryerState() {
|
|
11
|
-
return
|
|
11
|
+
return {
|
|
12
12
|
currentAttempt: 0,
|
|
13
13
|
executionCount: 0,
|
|
14
14
|
isExecuting: false,
|
|
@@ -17,7 +17,7 @@ function getDefaultAsyncRetryerState() {
|
|
|
17
17
|
lastResult: void 0,
|
|
18
18
|
status: "idle",
|
|
19
19
|
totalExecutionTime: 0
|
|
20
|
-
}
|
|
20
|
+
};
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.
|
|
@@ -28,6 +28,7 @@ function asyncRetryerOptions(options) {
|
|
|
28
28
|
const defaultOptions = {
|
|
29
29
|
backoff: "exponential",
|
|
30
30
|
baseWait: 1e3,
|
|
31
|
+
maxWait: Infinity,
|
|
31
32
|
enabled: true,
|
|
32
33
|
jitter: 0,
|
|
33
34
|
maxAttempts: 3,
|
|
@@ -51,6 +52,8 @@ const defaultOptions = {
|
|
|
51
52
|
* - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt
|
|
52
53
|
* - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).
|
|
53
54
|
* Set to a value between 0-1 to apply that percentage of random variation to each delay.
|
|
55
|
+
* - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).
|
|
56
|
+
* Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).
|
|
54
57
|
* - **Timeout Controls**: Set limits on execution time to prevent hanging operations:
|
|
55
58
|
* - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)
|
|
56
59
|
* - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)
|
|
@@ -91,7 +94,7 @@ const defaultOptions = {
|
|
|
91
94
|
* ## Usage
|
|
92
95
|
*
|
|
93
96
|
* - Use for async operations that may fail transiently and benefit from retrying.
|
|
94
|
-
* - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.
|
|
97
|
+
* - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.
|
|
95
98
|
* - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.
|
|
96
99
|
* - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.
|
|
97
100
|
* - Call `abort()` to cancel ongoing execution and pending retries.
|
|
@@ -285,6 +288,9 @@ var AsyncRetryer = class {
|
|
|
285
288
|
#getBaseWait = () => {
|
|
286
289
|
return require_utils.parseFunctionOrValue(this.options.baseWait, this);
|
|
287
290
|
};
|
|
291
|
+
#getMaxWait = () => {
|
|
292
|
+
return require_utils.parseFunctionOrValue(this.options.maxWait, this);
|
|
293
|
+
};
|
|
288
294
|
#calculateJitter = (waitTime) => {
|
|
289
295
|
const jitterAmount = this.options.jitter;
|
|
290
296
|
if (jitterAmount <= 0) return 0;
|
|
@@ -309,11 +315,11 @@ var AsyncRetryer = class {
|
|
|
309
315
|
case "exponential":
|
|
310
316
|
waitTime = baseWait * Math.pow(2, attempt - 1);
|
|
311
317
|
break;
|
|
312
|
-
case "fixed":
|
|
313
318
|
default:
|
|
314
319
|
waitTime = baseWait;
|
|
315
320
|
break;
|
|
316
321
|
}
|
|
322
|
+
waitTime = Math.min(waitTime, this.#getMaxWait());
|
|
317
323
|
const jitter = this.#calculateJitter(waitTime);
|
|
318
324
|
return Math.max(0, waitTime + jitter);
|
|
319
325
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"async-retryer.cjs","names":["Store","#getEnabled","#abortController","#setState","#getMaxAttempts","#calculateWait","parseFunctionOrValue","#getBaseWait","#calculateJitter"],"sources":["../src/async-retryer.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { AnyAsyncFunction } from './types'\n\nexport interface AsyncRetryerState<TFn extends AnyAsyncFunction> {\n /**\n * The current retry attempt number (0 when not executing)\n */\n currentAttempt: number\n /**\n * Total number of completed executions (successful or failed)\n */\n executionCount: number\n /**\n * Whether the retryer is currently executing the function\n */\n isExecuting: boolean\n /**\n * The most recent error encountered during execution\n */\n lastError: Error | undefined\n /**\n * Timestamp of the last execution completion in milliseconds\n */\n lastExecutionTime: number\n /**\n * The result from the most recent successful execution\n */\n lastResult: Awaited<ReturnType<TFn>> | undefined\n /**\n * Current execution status - 'disabled' when not enabled, 'idle' when ready, 'executing' when running\n */\n status: 'disabled' | 'idle' | 'executing' | 'retrying'\n /**\n * Total time spent executing (including retries) in milliseconds\n */\n totalExecutionTime: number\n}\n\n/**\n * Creates the default initial state for an AsyncRetryer instance\n * @returns The default state with all values reset to initial values\n */\nfunction getDefaultAsyncRetryerState<\n TFn extends AnyAsyncFunction,\n>(): AsyncRetryerState<TFn> {\n return structuredClone({\n currentAttempt: 0,\n executionCount: 0,\n isExecuting: false,\n lastError: undefined,\n lastExecutionTime: 0,\n lastResult: undefined,\n status: 'idle',\n totalExecutionTime: 0,\n })\n}\n\nexport interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {\n /**\n * The backoff strategy for retry delays:\n * - 'exponential': Wait time doubles with each attempt (1s, 2s, 4s, ...)\n * - 'linear': Wait time increases linearly (1s, 2s, 3s, ...)\n * - 'fixed': Same wait time for all attempts\n * @default 'exponential'\n */\n backoff?: 'linear' | 'exponential' | 'fixed'\n /**\n * Base wait time in milliseconds between retries, or a function that returns the wait time\n * @default 1000\n */\n baseWait?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Whether the retryer is enabled, or a function that determines if it's enabled\n * @default true\n */\n enabled?: boolean | ((retryer: AsyncRetryer<TFn>) => boolean)\n /**\n * Initial state to merge with the default state\n */\n initialState?: Partial<AsyncRetryerState<TFn>>\n /**\n * Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thundering herd.\n * @default 0\n */\n jitter?: number\n /**\n * Optional key to identify this async retryer instance.\n * If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of retry attempts, or a function that returns the max attempts\n * @default 3\n */\n maxAttempts?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Maximum execution time in milliseconds for a single function call before aborting\n * @default Infinity\n */\n maxExecutionTime?: number\n /**\n * Maximum total execution time in milliseconds for the entire retry operation before aborting\n * @default Infinity\n */\n maxTotalExecutionTime?: number\n /**\n * Callback invoked when the execution is aborted (manually or due to timeouts)\n */\n onAbort?: (\n reason: 'manual' | 'execution-timeout' | 'total-timeout' | 'new-execution',\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when any error occurs during execution (including retries)\n */\n onError?: (\n error: Error,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when the final error occurs after all retries are exhausted\n */\n onLastError?: (error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked before each retry attempt\n */\n onRetry?: (attempt: number, error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked after execution completes (success or failure) of each attempt\n */\n onSettled?: (args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when execution succeeds\n */\n onSuccess?: (\n result: Awaited<ReturnType<TFn>>,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)\n */\n onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)\n */\n onTotalExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Controls when errors are thrown:\n * - 'last': Only throw the final error after all retries are exhausted\n * - true: Throw every error immediately (disables retrying)\n * - false: Never throw errors, return undefined instead\n * @default 'last'\n */\n throwOnError?: boolean | 'last'\n}\n\n/**\n * Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.\n */\nexport function asyncRetryerOptions<\n TFn extends AnyAsyncFunction = AnyAsyncFunction,\n TOptions extends Partial<AsyncRetryerOptions<TFn>> = Partial<\n AsyncRetryerOptions<TFn>\n >,\n>(options: TOptions): TOptions {\n return options\n}\n\nconst defaultOptions: Omit<\n Required<AsyncRetryerOptions<any>>,\n | 'initialState'\n | 'key'\n | 'onAbort'\n | 'onError'\n | 'onLastError'\n | 'onRetry'\n | 'onSettled'\n | 'onSuccess'\n | 'onExecutionTimeout'\n | 'onTotalExecutionTimeout'\n> = {\n backoff: 'exponential',\n baseWait: 1000,\n enabled: true,\n jitter: 0,\n maxAttempts: 3,\n maxExecutionTime: Infinity,\n maxTotalExecutionTime: Infinity,\n throwOnError: 'last',\n}\n\n/**\n * Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies,\n * attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably\n * execute async operations that may fail intermittently, such as network requests or database operations,\n * by automatically retrying them according to your chosen policy.\n *\n * ## Retrying Concepts\n *\n * - **Retrying**: Automatically re-executes a failed async function up to a specified number of attempts.\n * Useful for handling transient errors (e.g., network flakiness, rate limits, temporary server issues).\n * - **Backoff Strategies**: Controls the delay between retry attempts (default: `'exponential'`):\n * - `'exponential'`: Wait time doubles with each attempt (1s, 2s, 4s, ...) - **DEFAULT**\n * - `'linear'`: Wait time increases linearly (1s, 2s, 3s, ...)\n * - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt\n * - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).\n * Set to a value between 0-1 to apply that percentage of random variation to each delay.\n * - **Timeout Controls**: Set limits on execution time to prevent hanging operations:\n * - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)\n * - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)\n * - **Abort & Cancellation**: Supports cancellation via an internal `AbortController`. Call `abort()` to stop retries.\n * Use `getAbortSignal()` to make your async function actually cancellable (e.g., with fetch requests).\n *\n * ## State Management\n *\n * Uses TanStack Store for fine-grained reactivity. State can be accessed via the `store.state` property.\n *\n * Available state properties:\n * - `currentAttempt`: The current retry attempt number (0 when not executing)\n * - `executionCount`: Total number of completed executions (successful or failed)\n * - `isExecuting`: Whether the retryer is currently executing the function\n * - `lastError`: The most recent error encountered during execution\n * - `lastExecutionTime`: Timestamp of the last execution completion in milliseconds\n * - `lastResult`: The result from the most recent successful execution\n * - `status`: Current execution status ('disabled' | 'idle' | 'executing' | 'retrying')\n * - `totalExecutionTime`: Total time spent executing (including retries) in milliseconds\n *\n * ## Error Handling\n *\n * The `throwOnError` option controls when errors are thrown (default: `'last'`):\n * - `'last'`: Only throws the final error after all retries are exhausted - **DEFAULT**\n * - `true`: Throws every error immediately (disables retrying)\n * - `false`: Never throws errors, returns `undefined` instead\n *\n * Callbacks for lifecycle management:\n * - `onAbort`: Called when execution is aborted (manually or due to timeouts)\n * - `onError`: Called for every error (including during retries)\n * - `onLastError`: Called only for the final error after all retries fail\n * - `onRetry`: Called before each retry attempt\n * - `onSettled`: Called after execution completes (success or failure) of each attempt\n * - `onSuccess`: Called when execution succeeds\n * - `onExecutionTimeout`: Called when a single execution attempt times out\n * - `onTotalExecutionTimeout`: Called when the total execution time times out\n *\n * ## Usage\n *\n * - Use for async operations that may fail transiently and benefit from retrying.\n * - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.\n * - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.\n * - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.\n * - Call `abort()` to cancel ongoing execution and pending retries.\n * - Call `reset()` to reset state and cancel execution.\n * - Use `getAbortSignal()` to make your async function cancellable.\n * - Use dynamic options (functions) for `maxAttempts`, `baseWait`, and `enabled` based on retryer state.\n *\n * **Important:** This class is designed for single-use execution. Calling `execute()` multiple times\n * on the same instance will abort previous executions. For multiple calls, create a new instance\n * each time.\n *\n * @example\n * ```typescript\n * // Retry a fetch operation up to 5 times with exponential backoff, jitter, and timeouts\n * const retryer = new AsyncRetryer(async (url: string) => {\n * const signal = retryer.getAbortSignal()\n * return await fetch(url, { signal })\n * }, {\n * maxAttempts: 5,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1, // Add 10% random variation to prevent thundering herd\n * maxExecutionTime: 5000, // Abort individual calls after 5 seconds\n * maxTotalExecutionTime: 30000, // Abort entire operation after 30 seconds\n * onRetry: (attempt, error) => console.log(`Retry attempt ${attempt} after error:`, error),\n * onSuccess: (result) => console.log('Success:', result),\n * onError: (error) => console.error('Error:', error),\n * onLastError: (error) => console.error('All retries failed:', error),\n * })\n *\n * const result = await retryer.execute('/api/data')\n * ```\n *\n * @template TFn The async function type to be retried.\n */\nexport class AsyncRetryer<TFn extends AnyAsyncFunction> {\n readonly store: Store<Readonly<AsyncRetryerState<TFn>>> = new Store(\n getDefaultAsyncRetryerState<TFn>(),\n )\n key: string | undefined\n options: AsyncRetryerOptions<TFn> & typeof defaultOptions\n #abortController: AbortController | null = null\n\n /**\n * Creates a new AsyncRetryer instance\n * @param fn The async function to retry\n * @param initialOptions Configuration options for the retryer\n */\n constructor(\n public fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n throwOnError:\n initialOptions.throwOnError ??\n (initialOptions.onError ? false : defaultOptions.throwOnError),\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-AsyncRetryer', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state as AsyncRetryerState<TFn>)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the retryer options\n * @param newOptions Partial options to merge with existing options\n */\n setOptions = (newOptions: Partial<AsyncRetryerOptions<TFn>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<AsyncRetryerState<TFn>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isExecuting, currentAttempt } = combinedState\n return {\n ...combinedState,\n status: !this.#getEnabled()\n ? 'disabled'\n : isExecuting && currentAttempt === 1\n ? 'executing'\n : isExecuting && currentAttempt > 1\n ? 'retrying'\n : 'idle',\n }\n })\n emitChange('AsyncRetryer', this)\n }\n\n #getEnabled = (): boolean => {\n return !!parseFunctionOrValue(this.options.enabled, this)\n }\n\n #getMaxAttempts = (): number => {\n return parseFunctionOrValue(this.options.maxAttempts, this)\n }\n\n #getBaseWait = (): number => {\n return parseFunctionOrValue(this.options.baseWait, this)\n }\n\n #calculateJitter = (waitTime: number): number => {\n const jitterAmount = this.options.jitter\n if (jitterAmount <= 0) return 0\n\n try {\n const crypto =\n typeof globalThis !== 'undefined' ? globalThis.crypto : undefined\n if (crypto?.getRandomValues) {\n const array = new Uint32Array(1)\n crypto.getRandomValues(array)\n // Convert to 0-1 range and apply jitter percentage\n const randomFactor = (array[0]! / 0xffffffff) * 2 - 1 // -1 to 1\n return Math.floor(waitTime * jitterAmount * randomFactor)\n }\n } catch {\n // No crypto available\n }\n return 0\n }\n\n #calculateWait = (attempt: number): number => {\n const baseWait = this.#getBaseWait()\n let waitTime: number\n\n switch (this.options.backoff) {\n case 'linear':\n waitTime = baseWait * attempt\n break\n case 'exponential':\n waitTime = baseWait * Math.pow(2, attempt - 1)\n break\n case 'fixed':\n default:\n waitTime = baseWait\n break\n }\n\n const jitter = this.#calculateJitter(waitTime)\n return Math.max(0, waitTime + jitter)\n }\n\n /**\n * Executes the function with retry logic\n * @param args Arguments to pass to the function\n * @returns The function result, or undefined if disabled or all retries failed (when throwOnError is false)\n * @throws The last error if throwOnError is true and all retries fail\n */\n execute = async (\n ...args: Parameters<TFn>\n ): Promise<Awaited<ReturnType<TFn>> | undefined> => {\n if (!this.#getEnabled()) {\n return undefined\n }\n\n // Cancel any existing execution\n this.abort('new-execution')\n\n const startTime = Date.now()\n let lastError: Error | undefined\n let result: Awaited<ReturnType<TFn>> | undefined\n\n this.#abortController = new AbortController()\n const signal = this.#abortController.signal\n\n this.#setState({\n isExecuting: true,\n currentAttempt: 0,\n lastError: undefined,\n })\n\n // Set up total execution timeout\n let totalTimeoutId: NodeJS.Timeout | undefined\n if (this.options.maxTotalExecutionTime !== Infinity) {\n totalTimeoutId = setTimeout(() => {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n }, this.options.maxTotalExecutionTime)\n }\n\n let isLastAttempt = false\n for (let attempt = 1; attempt <= this.#getMaxAttempts(); attempt++) {\n isLastAttempt = attempt === this.#getMaxAttempts()\n this.#setState({ currentAttempt: attempt })\n\n try {\n if (signal.aborted) {\n return undefined\n }\n\n // Check if total execution time has been exceeded\n const currentTotalTime = Date.now() - startTime\n if (\n this.options.maxTotalExecutionTime !== Infinity &&\n currentTotalTime >= this.options.maxTotalExecutionTime\n ) {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n return undefined\n }\n\n // Execute with individual timeout if specified\n if (this.options.maxExecutionTime === Infinity) {\n result = (await this.fn(...args)) as Awaited<ReturnType<TFn>>\n } else {\n result = (await Promise.race([\n this.fn(...args),\n new Promise<never>((_, reject) => {\n const timeout = setTimeout(() => {\n this.options.onExecutionTimeout?.(this)\n this.abort('execution-timeout')\n reject(\n new Error(\n `Execution timeout: ${this.options.maxExecutionTime}ms exceeded`,\n ),\n )\n }, this.options.maxExecutionTime)\n\n signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(timeout)\n reject(new Error('Aborted'))\n },\n { once: true },\n )\n }),\n ])) as Awaited<ReturnType<TFn>>\n }\n\n // Check if cancelled during execution\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (signal.aborted) {\n return undefined\n }\n\n const totalTime = Date.now() - startTime\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n isExecuting: false,\n lastExecutionTime: Date.now(),\n totalExecutionTime: totalTime,\n currentAttempt: 0,\n lastResult: result,\n })\n\n this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)\n\n return result\n } catch (error) {\n // Treat abort as a non-error cancellation outcome\n if (\n error &&\n typeof error === 'object' &&\n 'name' in error &&\n (error as Error).name === 'AbortError'\n ) {\n return undefined\n }\n lastError = error instanceof Error ? error : new Error(String(error))\n this.#setState({ lastError })\n\n // Call onError for every error (including during retries)\n this.options.onError?.(lastError, args, this)\n\n if (attempt < this.#getMaxAttempts()) {\n this.options.onRetry?.(attempt, lastError, this)\n\n const wait = this.#calculateWait(attempt)\n if (wait > 0) {\n // Eagerly reflect retrying status during the wait window\n this.#setState({ isExecuting: true, currentAttempt: attempt + 1 })\n await new Promise<void>((resolve) => {\n const timeout = setTimeout(() => {\n signal.removeEventListener('abort', onAbort)\n resolve()\n }, wait)\n const onAbort = () => {\n clearTimeout(timeout)\n signal.removeEventListener('abort', onAbort)\n resolve()\n }\n signal.addEventListener('abort', onAbort)\n })\n if (signal.aborted) {\n return undefined\n }\n }\n }\n } finally {\n this.options.onSettled?.(args, this)\n }\n }\n\n // Clean up total timeout\n if (totalTimeoutId) {\n clearTimeout(totalTimeoutId)\n }\n\n // Exhausted retries - finalize state\n this.#setState({ isExecuting: false })\n this.options.onLastError?.(lastError as Error, this)\n this.options.onSettled?.(args, this)\n\n if (\n (this.options.throwOnError === 'last' && isLastAttempt) ||\n this.options.throwOnError === true\n ) {\n throw lastError\n }\n\n return undefined\n }\n\n /**\n * Returns the current AbortSignal for the executing operation.\n * Use this signal in your async function to make it cancellable.\n * Returns null when not currently executing.\n *\n * @example\n * ```typescript\n * const retryer = new AsyncRetryer(async (userId: string) => {\n * const signal = retryer.getAbortSignal()\n * if (signal) {\n * return fetch(`/api/users/${userId}`, { signal })\n * }\n * return fetch(`/api/users/${userId}`)\n * })\n *\n * // Abort will now actually cancel the fetch\n * retryer.abort()\n * ```\n */\n getAbortSignal = (): AbortSignal | null => {\n return this.#abortController?.signal ?? null\n }\n\n /**\n * Cancels the current execution and any pending retries\n * @param reason The reason for the abort (defaults to 'manual')\n */\n abort = (\n reason:\n | 'manual'\n | 'execution-timeout'\n | 'total-timeout'\n | 'new-execution' = 'manual',\n ): void => {\n if (this.#abortController) {\n this.#abortController.abort()\n this.#abortController = null\n this.#setState({\n isExecuting: false,\n })\n this.options.onAbort?.(reason, this)\n }\n }\n\n /**\n * Resets the retryer to its initial state\n */\n reset = (): void => {\n this.#setState(getDefaultAsyncRetryerState<TFn>())\n }\n}\n\n/**\n * Creates a retry-enabled version of an async function. This is a convenience wrapper\n * around the AsyncRetryer class that returns the execute method.\n *\n * @param fn The async function to add retry functionality to\n * @param initialOptions Configuration options for the retry behavior\n * @returns A new function that executes the original with retry logic\n *\n * @example\n * ```typescript\n * // Define your async function normally\n * async function fetchData(url: string) {\n * const response = await fetch(url)\n * if (!response.ok) throw new Error('Request failed')\n * return response.json()\n * }\n *\n * // Create retry-enabled function\n * const fetchWithRetry = asyncRetry(fetchData, {\n * maxAttempts: 3,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1\n * })\n *\n * // Call it multiple times\n * const data1 = await fetchWithRetry('/api/data1')\n * const data2 = await fetchWithRetry('/api/data2')\n * ```\n */\nexport function asyncRetry<TFn extends AnyAsyncFunction>(\n fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n): (...args: Parameters<TFn>) => Promise<Awaited<ReturnType<TFn>> | undefined> {\n const retryer = new AsyncRetryer(fn, initialOptions)\n return retryer.execute\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"async-retryer.cjs","names":["Store","#getEnabled","#abortController","#setState","#getMaxAttempts","#calculateWait","parseFunctionOrValue","#getBaseWait","#getMaxWait","#calculateJitter"],"sources":["../src/async-retryer.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { AnyAsyncFunction } from './types'\n\nexport interface AsyncRetryerState<TFn extends AnyAsyncFunction> {\n /**\n * The current retry attempt number (0 when not executing)\n */\n currentAttempt: number\n /**\n * Total number of completed executions (successful or failed)\n */\n executionCount: number\n /**\n * Whether the retryer is currently executing the function\n */\n isExecuting: boolean\n /**\n * The most recent error encountered during execution\n */\n lastError: Error | undefined\n /**\n * Timestamp of the last execution completion in milliseconds\n */\n lastExecutionTime: number\n /**\n * The result from the most recent successful execution\n */\n lastResult: Awaited<ReturnType<TFn>> | undefined\n /**\n * Current execution status - 'disabled' when not enabled, 'idle' when ready, 'executing' when running\n */\n status: 'disabled' | 'idle' | 'executing' | 'retrying'\n /**\n * Total time spent executing (including retries) in milliseconds\n */\n totalExecutionTime: number\n}\n\n/**\n * Creates the default initial state for an AsyncRetryer instance\n * @returns The default state with all values reset to initial values\n */\nfunction getDefaultAsyncRetryerState<\n TFn extends AnyAsyncFunction,\n>(): AsyncRetryerState<TFn> {\n return {\n currentAttempt: 0,\n executionCount: 0,\n isExecuting: false,\n lastError: undefined,\n lastExecutionTime: 0,\n lastResult: undefined,\n status: 'idle',\n totalExecutionTime: 0,\n }\n}\n\nexport interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {\n /**\n * The backoff strategy for retry delays:\n * - 'exponential': Wait time doubles with each attempt (1s, 2s, 4s, ...)\n * - 'linear': Wait time increases linearly (1s, 2s, 3s, ...)\n * - 'fixed': Same wait time for all attempts\n * @default 'exponential'\n */\n backoff?: 'linear' | 'exponential' | 'fixed'\n /**\n * Base wait time in milliseconds between retries, or a function that returns the wait time\n * @default 1000\n */\n baseWait?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Whether the retryer is enabled, or a function that determines if it's enabled\n * @default true\n */\n enabled?: boolean | ((retryer: AsyncRetryer<TFn>) => boolean)\n /**\n * Initial state to merge with the default state\n */\n initialState?: Partial<AsyncRetryerState<TFn>>\n /**\n * Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thundering herd.\n * @default 0\n */\n jitter?: number\n /**\n * Optional key to identify this async retryer instance.\n * If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of retry attempts, or a function that returns the max attempts\n * @default 3\n */\n maxAttempts?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Maximum execution time in milliseconds for a single function call before aborting\n * @default Infinity\n */\n maxExecutionTime?: number\n /**\n * Maximum total execution time in milliseconds for the entire retry operation before aborting\n * @default Infinity\n */\n maxTotalExecutionTime?: number\n /**\n * Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time\n * @default Infinity\n */\n maxWait?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Callback invoked when the execution is aborted (manually or due to timeouts)\n */\n onAbort?: (\n reason: 'manual' | 'execution-timeout' | 'total-timeout' | 'new-execution',\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when any error occurs during execution (including retries)\n */\n onError?: (\n error: Error,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)\n */\n onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when the final error occurs after all retries are exhausted\n */\n onLastError?: (error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked before each retry attempt\n */\n onRetry?: (attempt: number, error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked after execution completes (success or failure) of each attempt\n */\n onSettled?: (args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when execution succeeds\n */\n onSuccess?: (\n result: Awaited<ReturnType<TFn>>,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)\n */\n onTotalExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Controls when errors are thrown:\n * - 'last': Only throw the final error after all retries are exhausted\n * - true: Throw every error immediately (disables retrying)\n * - false: Never throw errors, return undefined instead\n * @default 'last'\n */\n throwOnError?: boolean | 'last'\n}\n\n/**\n * Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.\n */\nexport function asyncRetryerOptions<\n TFn extends AnyAsyncFunction = AnyAsyncFunction,\n TOptions extends Partial<AsyncRetryerOptions<TFn>> = Partial<\n AsyncRetryerOptions<TFn>\n >,\n>(options: TOptions): TOptions {\n return options\n}\n\nconst defaultOptions: Omit<\n Required<AsyncRetryerOptions<any>>,\n | 'initialState'\n | 'key'\n | 'onAbort'\n | 'onError'\n | 'onLastError'\n | 'onRetry'\n | 'onSettled'\n | 'onSuccess'\n | 'onExecutionTimeout'\n | 'onTotalExecutionTimeout'\n> = {\n backoff: 'exponential',\n baseWait: 1000,\n maxWait: Infinity,\n enabled: true,\n jitter: 0,\n maxAttempts: 3,\n maxExecutionTime: Infinity,\n maxTotalExecutionTime: Infinity,\n throwOnError: 'last',\n}\n\n/**\n * Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies,\n * attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably\n * execute async operations that may fail intermittently, such as network requests or database operations,\n * by automatically retrying them according to your chosen policy.\n *\n * ## Retrying Concepts\n *\n * - **Retrying**: Automatically re-executes a failed async function up to a specified number of attempts.\n * Useful for handling transient errors (e.g., network flakiness, rate limits, temporary server issues).\n * - **Backoff Strategies**: Controls the delay between retry attempts (default: `'exponential'`):\n * - `'exponential'`: Wait time doubles with each attempt (1s, 2s, 4s, ...) - **DEFAULT**\n * - `'linear'`: Wait time increases linearly (1s, 2s, 3s, ...)\n * - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt\n * - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).\n * Set to a value between 0-1 to apply that percentage of random variation to each delay.\n * - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).\n * Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).\n * - **Timeout Controls**: Set limits on execution time to prevent hanging operations:\n * - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)\n * - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)\n * - **Abort & Cancellation**: Supports cancellation via an internal `AbortController`. Call `abort()` to stop retries.\n * Use `getAbortSignal()` to make your async function actually cancellable (e.g., with fetch requests).\n *\n * ## State Management\n *\n * Uses TanStack Store for fine-grained reactivity. State can be accessed via the `store.state` property.\n *\n * Available state properties:\n * - `currentAttempt`: The current retry attempt number (0 when not executing)\n * - `executionCount`: Total number of completed executions (successful or failed)\n * - `isExecuting`: Whether the retryer is currently executing the function\n * - `lastError`: The most recent error encountered during execution\n * - `lastExecutionTime`: Timestamp of the last execution completion in milliseconds\n * - `lastResult`: The result from the most recent successful execution\n * - `status`: Current execution status ('disabled' | 'idle' | 'executing' | 'retrying')\n * - `totalExecutionTime`: Total time spent executing (including retries) in milliseconds\n *\n * ## Error Handling\n *\n * The `throwOnError` option controls when errors are thrown (default: `'last'`):\n * - `'last'`: Only throws the final error after all retries are exhausted - **DEFAULT**\n * - `true`: Throws every error immediately (disables retrying)\n * - `false`: Never throws errors, returns `undefined` instead\n *\n * Callbacks for lifecycle management:\n * - `onAbort`: Called when execution is aborted (manually or due to timeouts)\n * - `onError`: Called for every error (including during retries)\n * - `onLastError`: Called only for the final error after all retries fail\n * - `onRetry`: Called before each retry attempt\n * - `onSettled`: Called after execution completes (success or failure) of each attempt\n * - `onSuccess`: Called when execution succeeds\n * - `onExecutionTimeout`: Called when a single execution attempt times out\n * - `onTotalExecutionTimeout`: Called when the total execution time times out\n *\n * ## Usage\n *\n * - Use for async operations that may fail transiently and benefit from retrying.\n * - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.\n * - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.\n * - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.\n * - Call `abort()` to cancel ongoing execution and pending retries.\n * - Call `reset()` to reset state and cancel execution.\n * - Use `getAbortSignal()` to make your async function cancellable.\n * - Use dynamic options (functions) for `maxAttempts`, `baseWait`, and `enabled` based on retryer state.\n *\n * **Important:** This class is designed for single-use execution. Calling `execute()` multiple times\n * on the same instance will abort previous executions. For multiple calls, create a new instance\n * each time.\n *\n * @example\n * ```typescript\n * // Retry a fetch operation up to 5 times with exponential backoff, jitter, and timeouts\n * const retryer = new AsyncRetryer(async (url: string) => {\n * const signal = retryer.getAbortSignal()\n * return await fetch(url, { signal })\n * }, {\n * maxAttempts: 5,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1, // Add 10% random variation to prevent thundering herd\n * maxExecutionTime: 5000, // Abort individual calls after 5 seconds\n * maxTotalExecutionTime: 30000, // Abort entire operation after 30 seconds\n * onRetry: (attempt, error) => console.log(`Retry attempt ${attempt} after error:`, error),\n * onSuccess: (result) => console.log('Success:', result),\n * onError: (error) => console.error('Error:', error),\n * onLastError: (error) => console.error('All retries failed:', error),\n * })\n *\n * const result = await retryer.execute('/api/data')\n * ```\n *\n * @template TFn The async function type to be retried.\n */\nexport class AsyncRetryer<TFn extends AnyAsyncFunction> {\n readonly store: Store<Readonly<AsyncRetryerState<TFn>>> = new Store(\n getDefaultAsyncRetryerState<TFn>(),\n )\n key: string | undefined\n options: AsyncRetryerOptions<TFn> & typeof defaultOptions\n #abortController: AbortController | null = null\n\n /**\n * Creates a new AsyncRetryer instance\n * @param fn The async function to retry\n * @param initialOptions Configuration options for the retryer\n */\n constructor(\n public fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n throwOnError:\n initialOptions.throwOnError ??\n (initialOptions.onError ? false : defaultOptions.throwOnError),\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-AsyncRetryer', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state as AsyncRetryerState<TFn>)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the retryer options\n * @param newOptions Partial options to merge with existing options\n */\n setOptions = (newOptions: Partial<AsyncRetryerOptions<TFn>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<AsyncRetryerState<TFn>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isExecuting, currentAttempt } = combinedState\n return {\n ...combinedState,\n status: !this.#getEnabled()\n ? 'disabled'\n : isExecuting && currentAttempt === 1\n ? 'executing'\n : isExecuting && currentAttempt > 1\n ? 'retrying'\n : 'idle',\n }\n })\n emitChange('AsyncRetryer', this)\n }\n\n #getEnabled = (): boolean => {\n return !!parseFunctionOrValue(this.options.enabled, this)\n }\n\n #getMaxAttempts = (): number => {\n return parseFunctionOrValue(this.options.maxAttempts, this)\n }\n\n #getBaseWait = (): number => {\n return parseFunctionOrValue(this.options.baseWait, this)\n }\n\n #getMaxWait = (): number => {\n return parseFunctionOrValue(this.options.maxWait, this)\n }\n\n #calculateJitter = (waitTime: number): number => {\n const jitterAmount = this.options.jitter\n if (jitterAmount <= 0) return 0\n\n try {\n const crypto =\n typeof globalThis !== 'undefined' ? globalThis.crypto : undefined\n if (crypto?.getRandomValues) {\n const array = new Uint32Array(1)\n crypto.getRandomValues(array)\n // Convert to 0-1 range and apply jitter percentage\n const randomFactor = (array[0]! / 0xffffffff) * 2 - 1 // -1 to 1\n return Math.floor(waitTime * jitterAmount * randomFactor)\n }\n } catch {\n // No crypto available\n }\n return 0\n }\n\n #calculateWait = (attempt: number): number => {\n const baseWait = this.#getBaseWait()\n let waitTime: number\n\n switch (this.options.backoff) {\n case 'linear':\n waitTime = baseWait * attempt\n break\n case 'exponential':\n waitTime = baseWait * Math.pow(2, attempt - 1)\n break\n case 'fixed':\n default:\n waitTime = baseWait\n break\n }\n\n waitTime = Math.min(waitTime, this.#getMaxWait())\n\n const jitter = this.#calculateJitter(waitTime)\n return Math.max(0, waitTime + jitter)\n }\n\n /**\n * Executes the function with retry logic\n * @param args Arguments to pass to the function\n * @returns The function result, or undefined if disabled or all retries failed (when throwOnError is false)\n * @throws The last error if throwOnError is true and all retries fail\n */\n execute = async (\n ...args: Parameters<TFn>\n ): Promise<Awaited<ReturnType<TFn>> | undefined> => {\n if (!this.#getEnabled()) {\n return undefined\n }\n\n // Cancel any existing execution\n this.abort('new-execution')\n\n const startTime = Date.now()\n let lastError: Error | undefined\n let result: Awaited<ReturnType<TFn>> | undefined\n\n this.#abortController = new AbortController()\n const signal = this.#abortController.signal\n\n this.#setState({\n isExecuting: true,\n currentAttempt: 0,\n lastError: undefined,\n })\n\n // Set up total execution timeout\n let totalTimeoutId: NodeJS.Timeout | undefined\n if (this.options.maxTotalExecutionTime !== Infinity) {\n totalTimeoutId = setTimeout(() => {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n }, this.options.maxTotalExecutionTime)\n }\n\n let isLastAttempt = false\n for (let attempt = 1; attempt <= this.#getMaxAttempts(); attempt++) {\n isLastAttempt = attempt === this.#getMaxAttempts()\n this.#setState({ currentAttempt: attempt })\n\n try {\n if (signal.aborted) {\n return undefined\n }\n\n // Check if total execution time has been exceeded\n const currentTotalTime = Date.now() - startTime\n if (\n this.options.maxTotalExecutionTime !== Infinity &&\n currentTotalTime >= this.options.maxTotalExecutionTime\n ) {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n return undefined\n }\n\n // Execute with individual timeout if specified\n if (this.options.maxExecutionTime === Infinity) {\n result = (await this.fn(...args)) as Awaited<ReturnType<TFn>>\n } else {\n result = (await Promise.race([\n this.fn(...args),\n new Promise<never>((_, reject) => {\n const timeout = setTimeout(() => {\n this.options.onExecutionTimeout?.(this)\n this.abort('execution-timeout')\n reject(\n new Error(\n `Execution timeout: ${this.options.maxExecutionTime}ms exceeded`,\n ),\n )\n }, this.options.maxExecutionTime)\n\n signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(timeout)\n reject(new Error('Aborted'))\n },\n { once: true },\n )\n }),\n ])) as Awaited<ReturnType<TFn>>\n }\n\n // Check if cancelled during execution\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (signal.aborted) {\n return undefined\n }\n\n const totalTime = Date.now() - startTime\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n isExecuting: false,\n lastExecutionTime: Date.now(),\n totalExecutionTime: totalTime,\n currentAttempt: 0,\n lastResult: result,\n })\n\n this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)\n\n return result\n } catch (error) {\n // Treat abort as a non-error cancellation outcome\n if (\n error &&\n typeof error === 'object' &&\n 'name' in error &&\n (error as Error).name === 'AbortError'\n ) {\n return undefined\n }\n lastError = error instanceof Error ? error : new Error(String(error))\n this.#setState({ lastError })\n\n // Call onError for every error (including during retries)\n this.options.onError?.(lastError, args, this)\n\n if (attempt < this.#getMaxAttempts()) {\n this.options.onRetry?.(attempt, lastError, this)\n\n const wait = this.#calculateWait(attempt)\n if (wait > 0) {\n // Eagerly reflect retrying status during the wait window\n this.#setState({ isExecuting: true, currentAttempt: attempt + 1 })\n await new Promise<void>((resolve) => {\n const timeout = setTimeout(() => {\n signal.removeEventListener('abort', onAbort)\n resolve()\n }, wait)\n const onAbort = () => {\n clearTimeout(timeout)\n signal.removeEventListener('abort', onAbort)\n resolve()\n }\n signal.addEventListener('abort', onAbort)\n })\n if (signal.aborted) {\n return undefined\n }\n }\n }\n } finally {\n this.options.onSettled?.(args, this)\n }\n }\n\n // Clean up total timeout\n if (totalTimeoutId) {\n clearTimeout(totalTimeoutId)\n }\n\n // Exhausted retries - finalize state\n this.#setState({ isExecuting: false })\n this.options.onLastError?.(lastError as Error, this)\n this.options.onSettled?.(args, this)\n\n if (\n (this.options.throwOnError === 'last' && isLastAttempt) ||\n this.options.throwOnError === true\n ) {\n throw lastError\n }\n\n return undefined\n }\n\n /**\n * Returns the current AbortSignal for the executing operation.\n * Use this signal in your async function to make it cancellable.\n * Returns null when not currently executing.\n *\n * @example\n * ```typescript\n * const retryer = new AsyncRetryer(async (userId: string) => {\n * const signal = retryer.getAbortSignal()\n * if (signal) {\n * return fetch(`/api/users/${userId}`, { signal })\n * }\n * return fetch(`/api/users/${userId}`)\n * })\n *\n * // Abort will now actually cancel the fetch\n * retryer.abort()\n * ```\n */\n getAbortSignal = (): AbortSignal | null => {\n return this.#abortController?.signal ?? null\n }\n\n /**\n * Cancels the current execution and any pending retries\n * @param reason The reason for the abort (defaults to 'manual')\n */\n abort = (\n reason:\n | 'manual'\n | 'execution-timeout'\n | 'total-timeout'\n | 'new-execution' = 'manual',\n ): void => {\n if (this.#abortController) {\n this.#abortController.abort()\n this.#abortController = null\n this.#setState({\n isExecuting: false,\n })\n this.options.onAbort?.(reason, this)\n }\n }\n\n /**\n * Resets the retryer to its initial state\n */\n reset = (): void => {\n this.#setState(getDefaultAsyncRetryerState<TFn>())\n }\n}\n\n/**\n * Creates a retry-enabled version of an async function. This is a convenience wrapper\n * around the AsyncRetryer class that returns the execute method.\n *\n * @param fn The async function to add retry functionality to\n * @param initialOptions Configuration options for the retry behavior\n * @returns A new function that executes the original with retry logic\n *\n * @example\n * ```typescript\n * // Define your async function normally\n * async function fetchData(url: string) {\n * const response = await fetch(url)\n * if (!response.ok) throw new Error('Request failed')\n * return response.json()\n * }\n *\n * // Create retry-enabled function\n * const fetchWithRetry = asyncRetry(fetchData, {\n * maxAttempts: 3,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1\n * })\n *\n * // Call it multiple times\n * const data1 = await fetchWithRetry('/api/data1')\n * const data2 = await fetchWithRetry('/api/data2')\n * ```\n */\nexport function asyncRetry<TFn extends AnyAsyncFunction>(\n fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n): (...args: Parameters<TFn>) => Promise<Awaited<ReturnType<TFn>> | undefined> {\n const retryer = new AsyncRetryer(fn, initialOptions)\n return retryer.execute\n}\n"],"mappings":"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"}
|
package/dist/async-retryer.d.cts
CHANGED
|
@@ -84,6 +84,11 @@ interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
84
84
|
* @default Infinity
|
|
85
85
|
*/
|
|
86
86
|
maxTotalExecutionTime?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time
|
|
89
|
+
* @default Infinity
|
|
90
|
+
*/
|
|
91
|
+
maxWait?: number | ((retryer: AsyncRetryer<TFn>) => number);
|
|
87
92
|
/**
|
|
88
93
|
* Callback invoked when the execution is aborted (manually or due to timeouts)
|
|
89
94
|
*/
|
|
@@ -92,6 +97,10 @@ interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
92
97
|
* Callback invoked when any error occurs during execution (including retries)
|
|
93
98
|
*/
|
|
94
99
|
onError?: (error: Error, args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
|
|
102
|
+
*/
|
|
103
|
+
onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void;
|
|
95
104
|
/**
|
|
96
105
|
* Callback invoked when the final error occurs after all retries are exhausted
|
|
97
106
|
*/
|
|
@@ -108,10 +117,6 @@ interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
108
117
|
* Callback invoked when execution succeeds
|
|
109
118
|
*/
|
|
110
119
|
onSuccess?: (result: Awaited<ReturnType<TFn>>, args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void;
|
|
111
|
-
/**
|
|
112
|
-
* Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
|
|
113
|
-
*/
|
|
114
|
-
onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void;
|
|
115
120
|
/**
|
|
116
121
|
* Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)
|
|
117
122
|
*/
|
|
@@ -146,6 +151,8 @@ declare const defaultOptions: Omit<Required<AsyncRetryerOptions<any>>, 'initialS
|
|
|
146
151
|
* - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt
|
|
147
152
|
* - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).
|
|
148
153
|
* Set to a value between 0-1 to apply that percentage of random variation to each delay.
|
|
154
|
+
* - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).
|
|
155
|
+
* Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).
|
|
149
156
|
* - **Timeout Controls**: Set limits on execution time to prevent hanging operations:
|
|
150
157
|
* - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)
|
|
151
158
|
* - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)
|
|
@@ -186,7 +193,7 @@ declare const defaultOptions: Omit<Required<AsyncRetryerOptions<any>>, 'initialS
|
|
|
186
193
|
* ## Usage
|
|
187
194
|
*
|
|
188
195
|
* - Use for async operations that may fail transiently and benefit from retrying.
|
|
189
|
-
* - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.
|
|
196
|
+
* - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.
|
|
190
197
|
* - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.
|
|
191
198
|
* - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.
|
|
192
199
|
* - Call `abort()` to cancel ongoing execution and pending retries.
|
package/dist/async-retryer.d.ts
CHANGED
|
@@ -84,6 +84,11 @@ interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
84
84
|
* @default Infinity
|
|
85
85
|
*/
|
|
86
86
|
maxTotalExecutionTime?: number;
|
|
87
|
+
/**
|
|
88
|
+
* Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time
|
|
89
|
+
* @default Infinity
|
|
90
|
+
*/
|
|
91
|
+
maxWait?: number | ((retryer: AsyncRetryer<TFn>) => number);
|
|
87
92
|
/**
|
|
88
93
|
* Callback invoked when the execution is aborted (manually or due to timeouts)
|
|
89
94
|
*/
|
|
@@ -92,6 +97,10 @@ interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
92
97
|
* Callback invoked when any error occurs during execution (including retries)
|
|
93
98
|
*/
|
|
94
99
|
onError?: (error: Error, args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void;
|
|
100
|
+
/**
|
|
101
|
+
* Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
|
|
102
|
+
*/
|
|
103
|
+
onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void;
|
|
95
104
|
/**
|
|
96
105
|
* Callback invoked when the final error occurs after all retries are exhausted
|
|
97
106
|
*/
|
|
@@ -108,10 +117,6 @@ interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
108
117
|
* Callback invoked when execution succeeds
|
|
109
118
|
*/
|
|
110
119
|
onSuccess?: (result: Awaited<ReturnType<TFn>>, args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void;
|
|
111
|
-
/**
|
|
112
|
-
* Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
|
|
113
|
-
*/
|
|
114
|
-
onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void;
|
|
115
120
|
/**
|
|
116
121
|
* Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)
|
|
117
122
|
*/
|
|
@@ -146,6 +151,8 @@ declare const defaultOptions: Omit<Required<AsyncRetryerOptions<any>>, 'initialS
|
|
|
146
151
|
* - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt
|
|
147
152
|
* - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).
|
|
148
153
|
* Set to a value between 0-1 to apply that percentage of random variation to each delay.
|
|
154
|
+
* - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).
|
|
155
|
+
* Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).
|
|
149
156
|
* - **Timeout Controls**: Set limits on execution time to prevent hanging operations:
|
|
150
157
|
* - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)
|
|
151
158
|
* - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)
|
|
@@ -186,7 +193,7 @@ declare const defaultOptions: Omit<Required<AsyncRetryerOptions<any>>, 'initialS
|
|
|
186
193
|
* ## Usage
|
|
187
194
|
*
|
|
188
195
|
* - Use for async operations that may fail transiently and benefit from retrying.
|
|
189
|
-
* - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.
|
|
196
|
+
* - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.
|
|
190
197
|
* - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.
|
|
191
198
|
* - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.
|
|
192
199
|
* - Call `abort()` to cancel ongoing execution and pending retries.
|
package/dist/async-retryer.js
CHANGED
|
@@ -8,7 +8,7 @@ import { Store } from "@tanstack/store";
|
|
|
8
8
|
* @returns The default state with all values reset to initial values
|
|
9
9
|
*/
|
|
10
10
|
function getDefaultAsyncRetryerState() {
|
|
11
|
-
return
|
|
11
|
+
return {
|
|
12
12
|
currentAttempt: 0,
|
|
13
13
|
executionCount: 0,
|
|
14
14
|
isExecuting: false,
|
|
@@ -17,7 +17,7 @@ function getDefaultAsyncRetryerState() {
|
|
|
17
17
|
lastResult: void 0,
|
|
18
18
|
status: "idle",
|
|
19
19
|
totalExecutionTime: 0
|
|
20
|
-
}
|
|
20
|
+
};
|
|
21
21
|
}
|
|
22
22
|
/**
|
|
23
23
|
* Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.
|
|
@@ -28,6 +28,7 @@ function asyncRetryerOptions(options) {
|
|
|
28
28
|
const defaultOptions = {
|
|
29
29
|
backoff: "exponential",
|
|
30
30
|
baseWait: 1e3,
|
|
31
|
+
maxWait: Infinity,
|
|
31
32
|
enabled: true,
|
|
32
33
|
jitter: 0,
|
|
33
34
|
maxAttempts: 3,
|
|
@@ -51,6 +52,8 @@ const defaultOptions = {
|
|
|
51
52
|
* - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt
|
|
52
53
|
* - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).
|
|
53
54
|
* Set to a value between 0-1 to apply that percentage of random variation to each delay.
|
|
55
|
+
* - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).
|
|
56
|
+
* Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).
|
|
54
57
|
* - **Timeout Controls**: Set limits on execution time to prevent hanging operations:
|
|
55
58
|
* - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)
|
|
56
59
|
* - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)
|
|
@@ -91,7 +94,7 @@ const defaultOptions = {
|
|
|
91
94
|
* ## Usage
|
|
92
95
|
*
|
|
93
96
|
* - Use for async operations that may fail transiently and benefit from retrying.
|
|
94
|
-
* - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.
|
|
97
|
+
* - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.
|
|
95
98
|
* - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.
|
|
96
99
|
* - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.
|
|
97
100
|
* - Call `abort()` to cancel ongoing execution and pending retries.
|
|
@@ -285,6 +288,9 @@ var AsyncRetryer = class {
|
|
|
285
288
|
#getBaseWait = () => {
|
|
286
289
|
return parseFunctionOrValue(this.options.baseWait, this);
|
|
287
290
|
};
|
|
291
|
+
#getMaxWait = () => {
|
|
292
|
+
return parseFunctionOrValue(this.options.maxWait, this);
|
|
293
|
+
};
|
|
288
294
|
#calculateJitter = (waitTime) => {
|
|
289
295
|
const jitterAmount = this.options.jitter;
|
|
290
296
|
if (jitterAmount <= 0) return 0;
|
|
@@ -309,11 +315,11 @@ var AsyncRetryer = class {
|
|
|
309
315
|
case "exponential":
|
|
310
316
|
waitTime = baseWait * Math.pow(2, attempt - 1);
|
|
311
317
|
break;
|
|
312
|
-
case "fixed":
|
|
313
318
|
default:
|
|
314
319
|
waitTime = baseWait;
|
|
315
320
|
break;
|
|
316
321
|
}
|
|
322
|
+
waitTime = Math.min(waitTime, this.#getMaxWait());
|
|
317
323
|
const jitter = this.#calculateJitter(waitTime);
|
|
318
324
|
return Math.max(0, waitTime + jitter);
|
|
319
325
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"async-retryer.js","names":["#getEnabled","#abortController","#setState","#getMaxAttempts","#calculateWait","#getBaseWait","#calculateJitter"],"sources":["../src/async-retryer.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { AnyAsyncFunction } from './types'\n\nexport interface AsyncRetryerState<TFn extends AnyAsyncFunction> {\n /**\n * The current retry attempt number (0 when not executing)\n */\n currentAttempt: number\n /**\n * Total number of completed executions (successful or failed)\n */\n executionCount: number\n /**\n * Whether the retryer is currently executing the function\n */\n isExecuting: boolean\n /**\n * The most recent error encountered during execution\n */\n lastError: Error | undefined\n /**\n * Timestamp of the last execution completion in milliseconds\n */\n lastExecutionTime: number\n /**\n * The result from the most recent successful execution\n */\n lastResult: Awaited<ReturnType<TFn>> | undefined\n /**\n * Current execution status - 'disabled' when not enabled, 'idle' when ready, 'executing' when running\n */\n status: 'disabled' | 'idle' | 'executing' | 'retrying'\n /**\n * Total time spent executing (including retries) in milliseconds\n */\n totalExecutionTime: number\n}\n\n/**\n * Creates the default initial state for an AsyncRetryer instance\n * @returns The default state with all values reset to initial values\n */\nfunction getDefaultAsyncRetryerState<\n TFn extends AnyAsyncFunction,\n>(): AsyncRetryerState<TFn> {\n return structuredClone({\n currentAttempt: 0,\n executionCount: 0,\n isExecuting: false,\n lastError: undefined,\n lastExecutionTime: 0,\n lastResult: undefined,\n status: 'idle',\n totalExecutionTime: 0,\n })\n}\n\nexport interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {\n /**\n * The backoff strategy for retry delays:\n * - 'exponential': Wait time doubles with each attempt (1s, 2s, 4s, ...)\n * - 'linear': Wait time increases linearly (1s, 2s, 3s, ...)\n * - 'fixed': Same wait time for all attempts\n * @default 'exponential'\n */\n backoff?: 'linear' | 'exponential' | 'fixed'\n /**\n * Base wait time in milliseconds between retries, or a function that returns the wait time\n * @default 1000\n */\n baseWait?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Whether the retryer is enabled, or a function that determines if it's enabled\n * @default true\n */\n enabled?: boolean | ((retryer: AsyncRetryer<TFn>) => boolean)\n /**\n * Initial state to merge with the default state\n */\n initialState?: Partial<AsyncRetryerState<TFn>>\n /**\n * Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thundering herd.\n * @default 0\n */\n jitter?: number\n /**\n * Optional key to identify this async retryer instance.\n * If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of retry attempts, or a function that returns the max attempts\n * @default 3\n */\n maxAttempts?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Maximum execution time in milliseconds for a single function call before aborting\n * @default Infinity\n */\n maxExecutionTime?: number\n /**\n * Maximum total execution time in milliseconds for the entire retry operation before aborting\n * @default Infinity\n */\n maxTotalExecutionTime?: number\n /**\n * Callback invoked when the execution is aborted (manually or due to timeouts)\n */\n onAbort?: (\n reason: 'manual' | 'execution-timeout' | 'total-timeout' | 'new-execution',\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when any error occurs during execution (including retries)\n */\n onError?: (\n error: Error,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when the final error occurs after all retries are exhausted\n */\n onLastError?: (error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked before each retry attempt\n */\n onRetry?: (attempt: number, error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked after execution completes (success or failure) of each attempt\n */\n onSettled?: (args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when execution succeeds\n */\n onSuccess?: (\n result: Awaited<ReturnType<TFn>>,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)\n */\n onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)\n */\n onTotalExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Controls when errors are thrown:\n * - 'last': Only throw the final error after all retries are exhausted\n * - true: Throw every error immediately (disables retrying)\n * - false: Never throw errors, return undefined instead\n * @default 'last'\n */\n throwOnError?: boolean | 'last'\n}\n\n/**\n * Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.\n */\nexport function asyncRetryerOptions<\n TFn extends AnyAsyncFunction = AnyAsyncFunction,\n TOptions extends Partial<AsyncRetryerOptions<TFn>> = Partial<\n AsyncRetryerOptions<TFn>\n >,\n>(options: TOptions): TOptions {\n return options\n}\n\nconst defaultOptions: Omit<\n Required<AsyncRetryerOptions<any>>,\n | 'initialState'\n | 'key'\n | 'onAbort'\n | 'onError'\n | 'onLastError'\n | 'onRetry'\n | 'onSettled'\n | 'onSuccess'\n | 'onExecutionTimeout'\n | 'onTotalExecutionTimeout'\n> = {\n backoff: 'exponential',\n baseWait: 1000,\n enabled: true,\n jitter: 0,\n maxAttempts: 3,\n maxExecutionTime: Infinity,\n maxTotalExecutionTime: Infinity,\n throwOnError: 'last',\n}\n\n/**\n * Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies,\n * attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably\n * execute async operations that may fail intermittently, such as network requests or database operations,\n * by automatically retrying them according to your chosen policy.\n *\n * ## Retrying Concepts\n *\n * - **Retrying**: Automatically re-executes a failed async function up to a specified number of attempts.\n * Useful for handling transient errors (e.g., network flakiness, rate limits, temporary server issues).\n * - **Backoff Strategies**: Controls the delay between retry attempts (default: `'exponential'`):\n * - `'exponential'`: Wait time doubles with each attempt (1s, 2s, 4s, ...) - **DEFAULT**\n * - `'linear'`: Wait time increases linearly (1s, 2s, 3s, ...)\n * - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt\n * - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).\n * Set to a value between 0-1 to apply that percentage of random variation to each delay.\n * - **Timeout Controls**: Set limits on execution time to prevent hanging operations:\n * - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)\n * - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)\n * - **Abort & Cancellation**: Supports cancellation via an internal `AbortController`. Call `abort()` to stop retries.\n * Use `getAbortSignal()` to make your async function actually cancellable (e.g., with fetch requests).\n *\n * ## State Management\n *\n * Uses TanStack Store for fine-grained reactivity. State can be accessed via the `store.state` property.\n *\n * Available state properties:\n * - `currentAttempt`: The current retry attempt number (0 when not executing)\n * - `executionCount`: Total number of completed executions (successful or failed)\n * - `isExecuting`: Whether the retryer is currently executing the function\n * - `lastError`: The most recent error encountered during execution\n * - `lastExecutionTime`: Timestamp of the last execution completion in milliseconds\n * - `lastResult`: The result from the most recent successful execution\n * - `status`: Current execution status ('disabled' | 'idle' | 'executing' | 'retrying')\n * - `totalExecutionTime`: Total time spent executing (including retries) in milliseconds\n *\n * ## Error Handling\n *\n * The `throwOnError` option controls when errors are thrown (default: `'last'`):\n * - `'last'`: Only throws the final error after all retries are exhausted - **DEFAULT**\n * - `true`: Throws every error immediately (disables retrying)\n * - `false`: Never throws errors, returns `undefined` instead\n *\n * Callbacks for lifecycle management:\n * - `onAbort`: Called when execution is aborted (manually or due to timeouts)\n * - `onError`: Called for every error (including during retries)\n * - `onLastError`: Called only for the final error after all retries fail\n * - `onRetry`: Called before each retry attempt\n * - `onSettled`: Called after execution completes (success or failure) of each attempt\n * - `onSuccess`: Called when execution succeeds\n * - `onExecutionTimeout`: Called when a single execution attempt times out\n * - `onTotalExecutionTimeout`: Called when the total execution time times out\n *\n * ## Usage\n *\n * - Use for async operations that may fail transiently and benefit from retrying.\n * - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.\n * - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.\n * - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.\n * - Call `abort()` to cancel ongoing execution and pending retries.\n * - Call `reset()` to reset state and cancel execution.\n * - Use `getAbortSignal()` to make your async function cancellable.\n * - Use dynamic options (functions) for `maxAttempts`, `baseWait`, and `enabled` based on retryer state.\n *\n * **Important:** This class is designed for single-use execution. Calling `execute()` multiple times\n * on the same instance will abort previous executions. For multiple calls, create a new instance\n * each time.\n *\n * @example\n * ```typescript\n * // Retry a fetch operation up to 5 times with exponential backoff, jitter, and timeouts\n * const retryer = new AsyncRetryer(async (url: string) => {\n * const signal = retryer.getAbortSignal()\n * return await fetch(url, { signal })\n * }, {\n * maxAttempts: 5,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1, // Add 10% random variation to prevent thundering herd\n * maxExecutionTime: 5000, // Abort individual calls after 5 seconds\n * maxTotalExecutionTime: 30000, // Abort entire operation after 30 seconds\n * onRetry: (attempt, error) => console.log(`Retry attempt ${attempt} after error:`, error),\n * onSuccess: (result) => console.log('Success:', result),\n * onError: (error) => console.error('Error:', error),\n * onLastError: (error) => console.error('All retries failed:', error),\n * })\n *\n * const result = await retryer.execute('/api/data')\n * ```\n *\n * @template TFn The async function type to be retried.\n */\nexport class AsyncRetryer<TFn extends AnyAsyncFunction> {\n readonly store: Store<Readonly<AsyncRetryerState<TFn>>> = new Store(\n getDefaultAsyncRetryerState<TFn>(),\n )\n key: string | undefined\n options: AsyncRetryerOptions<TFn> & typeof defaultOptions\n #abortController: AbortController | null = null\n\n /**\n * Creates a new AsyncRetryer instance\n * @param fn The async function to retry\n * @param initialOptions Configuration options for the retryer\n */\n constructor(\n public fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n throwOnError:\n initialOptions.throwOnError ??\n (initialOptions.onError ? false : defaultOptions.throwOnError),\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-AsyncRetryer', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state as AsyncRetryerState<TFn>)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the retryer options\n * @param newOptions Partial options to merge with existing options\n */\n setOptions = (newOptions: Partial<AsyncRetryerOptions<TFn>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<AsyncRetryerState<TFn>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isExecuting, currentAttempt } = combinedState\n return {\n ...combinedState,\n status: !this.#getEnabled()\n ? 'disabled'\n : isExecuting && currentAttempt === 1\n ? 'executing'\n : isExecuting && currentAttempt > 1\n ? 'retrying'\n : 'idle',\n }\n })\n emitChange('AsyncRetryer', this)\n }\n\n #getEnabled = (): boolean => {\n return !!parseFunctionOrValue(this.options.enabled, this)\n }\n\n #getMaxAttempts = (): number => {\n return parseFunctionOrValue(this.options.maxAttempts, this)\n }\n\n #getBaseWait = (): number => {\n return parseFunctionOrValue(this.options.baseWait, this)\n }\n\n #calculateJitter = (waitTime: number): number => {\n const jitterAmount = this.options.jitter\n if (jitterAmount <= 0) return 0\n\n try {\n const crypto =\n typeof globalThis !== 'undefined' ? globalThis.crypto : undefined\n if (crypto?.getRandomValues) {\n const array = new Uint32Array(1)\n crypto.getRandomValues(array)\n // Convert to 0-1 range and apply jitter percentage\n const randomFactor = (array[0]! / 0xffffffff) * 2 - 1 // -1 to 1\n return Math.floor(waitTime * jitterAmount * randomFactor)\n }\n } catch {\n // No crypto available\n }\n return 0\n }\n\n #calculateWait = (attempt: number): number => {\n const baseWait = this.#getBaseWait()\n let waitTime: number\n\n switch (this.options.backoff) {\n case 'linear':\n waitTime = baseWait * attempt\n break\n case 'exponential':\n waitTime = baseWait * Math.pow(2, attempt - 1)\n break\n case 'fixed':\n default:\n waitTime = baseWait\n break\n }\n\n const jitter = this.#calculateJitter(waitTime)\n return Math.max(0, waitTime + jitter)\n }\n\n /**\n * Executes the function with retry logic\n * @param args Arguments to pass to the function\n * @returns The function result, or undefined if disabled or all retries failed (when throwOnError is false)\n * @throws The last error if throwOnError is true and all retries fail\n */\n execute = async (\n ...args: Parameters<TFn>\n ): Promise<Awaited<ReturnType<TFn>> | undefined> => {\n if (!this.#getEnabled()) {\n return undefined\n }\n\n // Cancel any existing execution\n this.abort('new-execution')\n\n const startTime = Date.now()\n let lastError: Error | undefined\n let result: Awaited<ReturnType<TFn>> | undefined\n\n this.#abortController = new AbortController()\n const signal = this.#abortController.signal\n\n this.#setState({\n isExecuting: true,\n currentAttempt: 0,\n lastError: undefined,\n })\n\n // Set up total execution timeout\n let totalTimeoutId: NodeJS.Timeout | undefined\n if (this.options.maxTotalExecutionTime !== Infinity) {\n totalTimeoutId = setTimeout(() => {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n }, this.options.maxTotalExecutionTime)\n }\n\n let isLastAttempt = false\n for (let attempt = 1; attempt <= this.#getMaxAttempts(); attempt++) {\n isLastAttempt = attempt === this.#getMaxAttempts()\n this.#setState({ currentAttempt: attempt })\n\n try {\n if (signal.aborted) {\n return undefined\n }\n\n // Check if total execution time has been exceeded\n const currentTotalTime = Date.now() - startTime\n if (\n this.options.maxTotalExecutionTime !== Infinity &&\n currentTotalTime >= this.options.maxTotalExecutionTime\n ) {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n return undefined\n }\n\n // Execute with individual timeout if specified\n if (this.options.maxExecutionTime === Infinity) {\n result = (await this.fn(...args)) as Awaited<ReturnType<TFn>>\n } else {\n result = (await Promise.race([\n this.fn(...args),\n new Promise<never>((_, reject) => {\n const timeout = setTimeout(() => {\n this.options.onExecutionTimeout?.(this)\n this.abort('execution-timeout')\n reject(\n new Error(\n `Execution timeout: ${this.options.maxExecutionTime}ms exceeded`,\n ),\n )\n }, this.options.maxExecutionTime)\n\n signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(timeout)\n reject(new Error('Aborted'))\n },\n { once: true },\n )\n }),\n ])) as Awaited<ReturnType<TFn>>\n }\n\n // Check if cancelled during execution\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (signal.aborted) {\n return undefined\n }\n\n const totalTime = Date.now() - startTime\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n isExecuting: false,\n lastExecutionTime: Date.now(),\n totalExecutionTime: totalTime,\n currentAttempt: 0,\n lastResult: result,\n })\n\n this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)\n\n return result\n } catch (error) {\n // Treat abort as a non-error cancellation outcome\n if (\n error &&\n typeof error === 'object' &&\n 'name' in error &&\n (error as Error).name === 'AbortError'\n ) {\n return undefined\n }\n lastError = error instanceof Error ? error : new Error(String(error))\n this.#setState({ lastError })\n\n // Call onError for every error (including during retries)\n this.options.onError?.(lastError, args, this)\n\n if (attempt < this.#getMaxAttempts()) {\n this.options.onRetry?.(attempt, lastError, this)\n\n const wait = this.#calculateWait(attempt)\n if (wait > 0) {\n // Eagerly reflect retrying status during the wait window\n this.#setState({ isExecuting: true, currentAttempt: attempt + 1 })\n await new Promise<void>((resolve) => {\n const timeout = setTimeout(() => {\n signal.removeEventListener('abort', onAbort)\n resolve()\n }, wait)\n const onAbort = () => {\n clearTimeout(timeout)\n signal.removeEventListener('abort', onAbort)\n resolve()\n }\n signal.addEventListener('abort', onAbort)\n })\n if (signal.aborted) {\n return undefined\n }\n }\n }\n } finally {\n this.options.onSettled?.(args, this)\n }\n }\n\n // Clean up total timeout\n if (totalTimeoutId) {\n clearTimeout(totalTimeoutId)\n }\n\n // Exhausted retries - finalize state\n this.#setState({ isExecuting: false })\n this.options.onLastError?.(lastError as Error, this)\n this.options.onSettled?.(args, this)\n\n if (\n (this.options.throwOnError === 'last' && isLastAttempt) ||\n this.options.throwOnError === true\n ) {\n throw lastError\n }\n\n return undefined\n }\n\n /**\n * Returns the current AbortSignal for the executing operation.\n * Use this signal in your async function to make it cancellable.\n * Returns null when not currently executing.\n *\n * @example\n * ```typescript\n * const retryer = new AsyncRetryer(async (userId: string) => {\n * const signal = retryer.getAbortSignal()\n * if (signal) {\n * return fetch(`/api/users/${userId}`, { signal })\n * }\n * return fetch(`/api/users/${userId}`)\n * })\n *\n * // Abort will now actually cancel the fetch\n * retryer.abort()\n * ```\n */\n getAbortSignal = (): AbortSignal | null => {\n return this.#abortController?.signal ?? null\n }\n\n /**\n * Cancels the current execution and any pending retries\n * @param reason The reason for the abort (defaults to 'manual')\n */\n abort = (\n reason:\n | 'manual'\n | 'execution-timeout'\n | 'total-timeout'\n | 'new-execution' = 'manual',\n ): void => {\n if (this.#abortController) {\n this.#abortController.abort()\n this.#abortController = null\n this.#setState({\n isExecuting: false,\n })\n this.options.onAbort?.(reason, this)\n }\n }\n\n /**\n * Resets the retryer to its initial state\n */\n reset = (): void => {\n this.#setState(getDefaultAsyncRetryerState<TFn>())\n }\n}\n\n/**\n * Creates a retry-enabled version of an async function. This is a convenience wrapper\n * around the AsyncRetryer class that returns the execute method.\n *\n * @param fn The async function to add retry functionality to\n * @param initialOptions Configuration options for the retry behavior\n * @returns A new function that executes the original with retry logic\n *\n * @example\n * ```typescript\n * // Define your async function normally\n * async function fetchData(url: string) {\n * const response = await fetch(url)\n * if (!response.ok) throw new Error('Request failed')\n * return response.json()\n * }\n *\n * // Create retry-enabled function\n * const fetchWithRetry = asyncRetry(fetchData, {\n * maxAttempts: 3,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1\n * })\n *\n * // Call it multiple times\n * const data1 = await fetchWithRetry('/api/data1')\n * const data2 = await fetchWithRetry('/api/data2')\n * ```\n */\nexport function asyncRetry<TFn extends AnyAsyncFunction>(\n fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n): (...args: Parameters<TFn>) => Promise<Awaited<ReturnType<TFn>> | undefined> {\n const retryer = new AsyncRetryer(fn, initialOptions)\n return retryer.execute\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"async-retryer.js","names":["#getEnabled","#abortController","#setState","#getMaxAttempts","#calculateWait","#getBaseWait","#getMaxWait","#calculateJitter"],"sources":["../src/async-retryer.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { AnyAsyncFunction } from './types'\n\nexport interface AsyncRetryerState<TFn extends AnyAsyncFunction> {\n /**\n * The current retry attempt number (0 when not executing)\n */\n currentAttempt: number\n /**\n * Total number of completed executions (successful or failed)\n */\n executionCount: number\n /**\n * Whether the retryer is currently executing the function\n */\n isExecuting: boolean\n /**\n * The most recent error encountered during execution\n */\n lastError: Error | undefined\n /**\n * Timestamp of the last execution completion in milliseconds\n */\n lastExecutionTime: number\n /**\n * The result from the most recent successful execution\n */\n lastResult: Awaited<ReturnType<TFn>> | undefined\n /**\n * Current execution status - 'disabled' when not enabled, 'idle' when ready, 'executing' when running\n */\n status: 'disabled' | 'idle' | 'executing' | 'retrying'\n /**\n * Total time spent executing (including retries) in milliseconds\n */\n totalExecutionTime: number\n}\n\n/**\n * Creates the default initial state for an AsyncRetryer instance\n * @returns The default state with all values reset to initial values\n */\nfunction getDefaultAsyncRetryerState<\n TFn extends AnyAsyncFunction,\n>(): AsyncRetryerState<TFn> {\n return {\n currentAttempt: 0,\n executionCount: 0,\n isExecuting: false,\n lastError: undefined,\n lastExecutionTime: 0,\n lastResult: undefined,\n status: 'idle',\n totalExecutionTime: 0,\n }\n}\n\nexport interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {\n /**\n * The backoff strategy for retry delays:\n * - 'exponential': Wait time doubles with each attempt (1s, 2s, 4s, ...)\n * - 'linear': Wait time increases linearly (1s, 2s, 3s, ...)\n * - 'fixed': Same wait time for all attempts\n * @default 'exponential'\n */\n backoff?: 'linear' | 'exponential' | 'fixed'\n /**\n * Base wait time in milliseconds between retries, or a function that returns the wait time\n * @default 1000\n */\n baseWait?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Whether the retryer is enabled, or a function that determines if it's enabled\n * @default true\n */\n enabled?: boolean | ((retryer: AsyncRetryer<TFn>) => boolean)\n /**\n * Initial state to merge with the default state\n */\n initialState?: Partial<AsyncRetryerState<TFn>>\n /**\n * Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thundering herd.\n * @default 0\n */\n jitter?: number\n /**\n * Optional key to identify this async retryer instance.\n * If provided, the async retryer will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of retry attempts, or a function that returns the max attempts\n * @default 3\n */\n maxAttempts?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Maximum execution time in milliseconds for a single function call before aborting\n * @default Infinity\n */\n maxExecutionTime?: number\n /**\n * Maximum total execution time in milliseconds for the entire retry operation before aborting\n * @default Infinity\n */\n maxTotalExecutionTime?: number\n /**\n * Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time\n * @default Infinity\n */\n maxWait?: number | ((retryer: AsyncRetryer<TFn>) => number)\n /**\n * Callback invoked when the execution is aborted (manually or due to timeouts)\n */\n onAbort?: (\n reason: 'manual' | 'execution-timeout' | 'total-timeout' | 'new-execution',\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when any error occurs during execution (including retries)\n */\n onError?: (\n error: Error,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)\n */\n onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when the final error occurs after all retries are exhausted\n */\n onLastError?: (error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked before each retry attempt\n */\n onRetry?: (attempt: number, error: Error, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked after execution completes (success or failure) of each attempt\n */\n onSettled?: (args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void\n /**\n * Callback invoked when execution succeeds\n */\n onSuccess?: (\n result: Awaited<ReturnType<TFn>>,\n args: Parameters<TFn>,\n retryer: AsyncRetryer<TFn>,\n ) => void\n /**\n * Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)\n */\n onTotalExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void\n /**\n * Controls when errors are thrown:\n * - 'last': Only throw the final error after all retries are exhausted\n * - true: Throw every error immediately (disables retrying)\n * - false: Never throw errors, return undefined instead\n * @default 'last'\n */\n throwOnError?: boolean | 'last'\n}\n\n/**\n * Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.\n */\nexport function asyncRetryerOptions<\n TFn extends AnyAsyncFunction = AnyAsyncFunction,\n TOptions extends Partial<AsyncRetryerOptions<TFn>> = Partial<\n AsyncRetryerOptions<TFn>\n >,\n>(options: TOptions): TOptions {\n return options\n}\n\nconst defaultOptions: Omit<\n Required<AsyncRetryerOptions<any>>,\n | 'initialState'\n | 'key'\n | 'onAbort'\n | 'onError'\n | 'onLastError'\n | 'onRetry'\n | 'onSettled'\n | 'onSuccess'\n | 'onExecutionTimeout'\n | 'onTotalExecutionTimeout'\n> = {\n backoff: 'exponential',\n baseWait: 1000,\n maxWait: Infinity,\n enabled: true,\n jitter: 0,\n maxAttempts: 3,\n maxExecutionTime: Infinity,\n maxTotalExecutionTime: Infinity,\n throwOnError: 'last',\n}\n\n/**\n * Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies,\n * attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably\n * execute async operations that may fail intermittently, such as network requests or database operations,\n * by automatically retrying them according to your chosen policy.\n *\n * ## Retrying Concepts\n *\n * - **Retrying**: Automatically re-executes a failed async function up to a specified number of attempts.\n * Useful for handling transient errors (e.g., network flakiness, rate limits, temporary server issues).\n * - **Backoff Strategies**: Controls the delay between retry attempts (default: `'exponential'`):\n * - `'exponential'`: Wait time doubles with each attempt (1s, 2s, 4s, ...) - **DEFAULT**\n * - `'linear'`: Wait time increases linearly (1s, 2s, 3s, ...)\n * - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt\n * - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).\n * Set to a value between 0-1 to apply that percentage of random variation to each delay.\n * - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).\n * Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).\n * - **Timeout Controls**: Set limits on execution time to prevent hanging operations:\n * - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)\n * - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)\n * - **Abort & Cancellation**: Supports cancellation via an internal `AbortController`. Call `abort()` to stop retries.\n * Use `getAbortSignal()` to make your async function actually cancellable (e.g., with fetch requests).\n *\n * ## State Management\n *\n * Uses TanStack Store for fine-grained reactivity. State can be accessed via the `store.state` property.\n *\n * Available state properties:\n * - `currentAttempt`: The current retry attempt number (0 when not executing)\n * - `executionCount`: Total number of completed executions (successful or failed)\n * - `isExecuting`: Whether the retryer is currently executing the function\n * - `lastError`: The most recent error encountered during execution\n * - `lastExecutionTime`: Timestamp of the last execution completion in milliseconds\n * - `lastResult`: The result from the most recent successful execution\n * - `status`: Current execution status ('disabled' | 'idle' | 'executing' | 'retrying')\n * - `totalExecutionTime`: Total time spent executing (including retries) in milliseconds\n *\n * ## Error Handling\n *\n * The `throwOnError` option controls when errors are thrown (default: `'last'`):\n * - `'last'`: Only throws the final error after all retries are exhausted - **DEFAULT**\n * - `true`: Throws every error immediately (disables retrying)\n * - `false`: Never throws errors, returns `undefined` instead\n *\n * Callbacks for lifecycle management:\n * - `onAbort`: Called when execution is aborted (manually or due to timeouts)\n * - `onError`: Called for every error (including during retries)\n * - `onLastError`: Called only for the final error after all retries fail\n * - `onRetry`: Called before each retry attempt\n * - `onSettled`: Called after execution completes (success or failure) of each attempt\n * - `onSuccess`: Called when execution succeeds\n * - `onExecutionTimeout`: Called when a single execution attempt times out\n * - `onTotalExecutionTimeout`: Called when the total execution time times out\n *\n * ## Usage\n *\n * - Use for async operations that may fail transiently and benefit from retrying.\n * - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.\n * - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.\n * - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.\n * - Call `abort()` to cancel ongoing execution and pending retries.\n * - Call `reset()` to reset state and cancel execution.\n * - Use `getAbortSignal()` to make your async function cancellable.\n * - Use dynamic options (functions) for `maxAttempts`, `baseWait`, and `enabled` based on retryer state.\n *\n * **Important:** This class is designed for single-use execution. Calling `execute()` multiple times\n * on the same instance will abort previous executions. For multiple calls, create a new instance\n * each time.\n *\n * @example\n * ```typescript\n * // Retry a fetch operation up to 5 times with exponential backoff, jitter, and timeouts\n * const retryer = new AsyncRetryer(async (url: string) => {\n * const signal = retryer.getAbortSignal()\n * return await fetch(url, { signal })\n * }, {\n * maxAttempts: 5,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1, // Add 10% random variation to prevent thundering herd\n * maxExecutionTime: 5000, // Abort individual calls after 5 seconds\n * maxTotalExecutionTime: 30000, // Abort entire operation after 30 seconds\n * onRetry: (attempt, error) => console.log(`Retry attempt ${attempt} after error:`, error),\n * onSuccess: (result) => console.log('Success:', result),\n * onError: (error) => console.error('Error:', error),\n * onLastError: (error) => console.error('All retries failed:', error),\n * })\n *\n * const result = await retryer.execute('/api/data')\n * ```\n *\n * @template TFn The async function type to be retried.\n */\nexport class AsyncRetryer<TFn extends AnyAsyncFunction> {\n readonly store: Store<Readonly<AsyncRetryerState<TFn>>> = new Store(\n getDefaultAsyncRetryerState<TFn>(),\n )\n key: string | undefined\n options: AsyncRetryerOptions<TFn> & typeof defaultOptions\n #abortController: AbortController | null = null\n\n /**\n * Creates a new AsyncRetryer instance\n * @param fn The async function to retry\n * @param initialOptions Configuration options for the retryer\n */\n constructor(\n public fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n throwOnError:\n initialOptions.throwOnError ??\n (initialOptions.onError ? false : defaultOptions.throwOnError),\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-AsyncRetryer', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state as AsyncRetryerState<TFn>)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the retryer options\n * @param newOptions Partial options to merge with existing options\n */\n setOptions = (newOptions: Partial<AsyncRetryerOptions<TFn>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<AsyncRetryerState<TFn>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isExecuting, currentAttempt } = combinedState\n return {\n ...combinedState,\n status: !this.#getEnabled()\n ? 'disabled'\n : isExecuting && currentAttempt === 1\n ? 'executing'\n : isExecuting && currentAttempt > 1\n ? 'retrying'\n : 'idle',\n }\n })\n emitChange('AsyncRetryer', this)\n }\n\n #getEnabled = (): boolean => {\n return !!parseFunctionOrValue(this.options.enabled, this)\n }\n\n #getMaxAttempts = (): number => {\n return parseFunctionOrValue(this.options.maxAttempts, this)\n }\n\n #getBaseWait = (): number => {\n return parseFunctionOrValue(this.options.baseWait, this)\n }\n\n #getMaxWait = (): number => {\n return parseFunctionOrValue(this.options.maxWait, this)\n }\n\n #calculateJitter = (waitTime: number): number => {\n const jitterAmount = this.options.jitter\n if (jitterAmount <= 0) return 0\n\n try {\n const crypto =\n typeof globalThis !== 'undefined' ? globalThis.crypto : undefined\n if (crypto?.getRandomValues) {\n const array = new Uint32Array(1)\n crypto.getRandomValues(array)\n // Convert to 0-1 range and apply jitter percentage\n const randomFactor = (array[0]! / 0xffffffff) * 2 - 1 // -1 to 1\n return Math.floor(waitTime * jitterAmount * randomFactor)\n }\n } catch {\n // No crypto available\n }\n return 0\n }\n\n #calculateWait = (attempt: number): number => {\n const baseWait = this.#getBaseWait()\n let waitTime: number\n\n switch (this.options.backoff) {\n case 'linear':\n waitTime = baseWait * attempt\n break\n case 'exponential':\n waitTime = baseWait * Math.pow(2, attempt - 1)\n break\n case 'fixed':\n default:\n waitTime = baseWait\n break\n }\n\n waitTime = Math.min(waitTime, this.#getMaxWait())\n\n const jitter = this.#calculateJitter(waitTime)\n return Math.max(0, waitTime + jitter)\n }\n\n /**\n * Executes the function with retry logic\n * @param args Arguments to pass to the function\n * @returns The function result, or undefined if disabled or all retries failed (when throwOnError is false)\n * @throws The last error if throwOnError is true and all retries fail\n */\n execute = async (\n ...args: Parameters<TFn>\n ): Promise<Awaited<ReturnType<TFn>> | undefined> => {\n if (!this.#getEnabled()) {\n return undefined\n }\n\n // Cancel any existing execution\n this.abort('new-execution')\n\n const startTime = Date.now()\n let lastError: Error | undefined\n let result: Awaited<ReturnType<TFn>> | undefined\n\n this.#abortController = new AbortController()\n const signal = this.#abortController.signal\n\n this.#setState({\n isExecuting: true,\n currentAttempt: 0,\n lastError: undefined,\n })\n\n // Set up total execution timeout\n let totalTimeoutId: NodeJS.Timeout | undefined\n if (this.options.maxTotalExecutionTime !== Infinity) {\n totalTimeoutId = setTimeout(() => {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n }, this.options.maxTotalExecutionTime)\n }\n\n let isLastAttempt = false\n for (let attempt = 1; attempt <= this.#getMaxAttempts(); attempt++) {\n isLastAttempt = attempt === this.#getMaxAttempts()\n this.#setState({ currentAttempt: attempt })\n\n try {\n if (signal.aborted) {\n return undefined\n }\n\n // Check if total execution time has been exceeded\n const currentTotalTime = Date.now() - startTime\n if (\n this.options.maxTotalExecutionTime !== Infinity &&\n currentTotalTime >= this.options.maxTotalExecutionTime\n ) {\n this.options.onTotalExecutionTimeout?.(this)\n this.abort('total-timeout')\n return undefined\n }\n\n // Execute with individual timeout if specified\n if (this.options.maxExecutionTime === Infinity) {\n result = (await this.fn(...args)) as Awaited<ReturnType<TFn>>\n } else {\n result = (await Promise.race([\n this.fn(...args),\n new Promise<never>((_, reject) => {\n const timeout = setTimeout(() => {\n this.options.onExecutionTimeout?.(this)\n this.abort('execution-timeout')\n reject(\n new Error(\n `Execution timeout: ${this.options.maxExecutionTime}ms exceeded`,\n ),\n )\n }, this.options.maxExecutionTime)\n\n signal.addEventListener(\n 'abort',\n () => {\n clearTimeout(timeout)\n reject(new Error('Aborted'))\n },\n { once: true },\n )\n }),\n ])) as Awaited<ReturnType<TFn>>\n }\n\n // Check if cancelled during execution\n // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition\n if (signal.aborted) {\n return undefined\n }\n\n const totalTime = Date.now() - startTime\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n isExecuting: false,\n lastExecutionTime: Date.now(),\n totalExecutionTime: totalTime,\n currentAttempt: 0,\n lastResult: result,\n })\n\n this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)\n\n return result\n } catch (error) {\n // Treat abort as a non-error cancellation outcome\n if (\n error &&\n typeof error === 'object' &&\n 'name' in error &&\n (error as Error).name === 'AbortError'\n ) {\n return undefined\n }\n lastError = error instanceof Error ? error : new Error(String(error))\n this.#setState({ lastError })\n\n // Call onError for every error (including during retries)\n this.options.onError?.(lastError, args, this)\n\n if (attempt < this.#getMaxAttempts()) {\n this.options.onRetry?.(attempt, lastError, this)\n\n const wait = this.#calculateWait(attempt)\n if (wait > 0) {\n // Eagerly reflect retrying status during the wait window\n this.#setState({ isExecuting: true, currentAttempt: attempt + 1 })\n await new Promise<void>((resolve) => {\n const timeout = setTimeout(() => {\n signal.removeEventListener('abort', onAbort)\n resolve()\n }, wait)\n const onAbort = () => {\n clearTimeout(timeout)\n signal.removeEventListener('abort', onAbort)\n resolve()\n }\n signal.addEventListener('abort', onAbort)\n })\n if (signal.aborted) {\n return undefined\n }\n }\n }\n } finally {\n this.options.onSettled?.(args, this)\n }\n }\n\n // Clean up total timeout\n if (totalTimeoutId) {\n clearTimeout(totalTimeoutId)\n }\n\n // Exhausted retries - finalize state\n this.#setState({ isExecuting: false })\n this.options.onLastError?.(lastError as Error, this)\n this.options.onSettled?.(args, this)\n\n if (\n (this.options.throwOnError === 'last' && isLastAttempt) ||\n this.options.throwOnError === true\n ) {\n throw lastError\n }\n\n return undefined\n }\n\n /**\n * Returns the current AbortSignal for the executing operation.\n * Use this signal in your async function to make it cancellable.\n * Returns null when not currently executing.\n *\n * @example\n * ```typescript\n * const retryer = new AsyncRetryer(async (userId: string) => {\n * const signal = retryer.getAbortSignal()\n * if (signal) {\n * return fetch(`/api/users/${userId}`, { signal })\n * }\n * return fetch(`/api/users/${userId}`)\n * })\n *\n * // Abort will now actually cancel the fetch\n * retryer.abort()\n * ```\n */\n getAbortSignal = (): AbortSignal | null => {\n return this.#abortController?.signal ?? null\n }\n\n /**\n * Cancels the current execution and any pending retries\n * @param reason The reason for the abort (defaults to 'manual')\n */\n abort = (\n reason:\n | 'manual'\n | 'execution-timeout'\n | 'total-timeout'\n | 'new-execution' = 'manual',\n ): void => {\n if (this.#abortController) {\n this.#abortController.abort()\n this.#abortController = null\n this.#setState({\n isExecuting: false,\n })\n this.options.onAbort?.(reason, this)\n }\n }\n\n /**\n * Resets the retryer to its initial state\n */\n reset = (): void => {\n this.#setState(getDefaultAsyncRetryerState<TFn>())\n }\n}\n\n/**\n * Creates a retry-enabled version of an async function. This is a convenience wrapper\n * around the AsyncRetryer class that returns the execute method.\n *\n * @param fn The async function to add retry functionality to\n * @param initialOptions Configuration options for the retry behavior\n * @returns A new function that executes the original with retry logic\n *\n * @example\n * ```typescript\n * // Define your async function normally\n * async function fetchData(url: string) {\n * const response = await fetch(url)\n * if (!response.ok) throw new Error('Request failed')\n * return response.json()\n * }\n *\n * // Create retry-enabled function\n * const fetchWithRetry = asyncRetry(fetchData, {\n * maxAttempts: 3,\n * backoff: 'exponential',\n * baseWait: 1000,\n * jitter: 0.1\n * })\n *\n * // Call it multiple times\n * const data1 = await fetchWithRetry('/api/data1')\n * const data2 = await fetchWithRetry('/api/data2')\n * ```\n */\nexport function asyncRetry<TFn extends AnyAsyncFunction>(\n fn: TFn,\n initialOptions: AsyncRetryerOptions<TFn> = {},\n): (...args: Parameters<TFn>) => Promise<Awaited<ReturnType<TFn>> | undefined> {\n const retryer = new AsyncRetryer(fn, initialOptions)\n return retryer.execute\n}\n"],"mappings":"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"}
|
package/dist/batcher.cjs
CHANGED
|
@@ -147,15 +147,15 @@ var Batcher = class {
|
|
|
147
147
|
*/
|
|
148
148
|
#execute = () => {
|
|
149
149
|
if (this.store.state.items.length === 0) return;
|
|
150
|
-
const batch
|
|
150
|
+
const batch = this.peekAllItems();
|
|
151
151
|
this.clear();
|
|
152
152
|
this.options.onItemsChange?.(this);
|
|
153
|
-
this.fn(batch
|
|
153
|
+
this.fn(batch);
|
|
154
154
|
this.#setState({
|
|
155
155
|
executionCount: this.store.state.executionCount + 1,
|
|
156
|
-
totalItemsProcessed: this.store.state.totalItemsProcessed + batch
|
|
156
|
+
totalItemsProcessed: this.store.state.totalItemsProcessed + batch.length
|
|
157
157
|
});
|
|
158
|
-
this.options.onExecute?.(batch
|
|
158
|
+
this.options.onExecute?.(batch, this);
|
|
159
159
|
};
|
|
160
160
|
#clearTimeout = () => {
|
|
161
161
|
if (this.#timeoutId) {
|
package/dist/batcher.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"batcher.cjs","names":["Store","#setState","#execute","#clearTimeout","#timeoutId","#getWait","parseFunctionOrValue","batch"],"sources":["../src/batcher.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { OptionalKeys } from './types'\n\nexport interface BatcherState<TValue> {\n /**\n * Number of batch executions that have been completed\n */\n executionCount: number\n /**\n * Whether the batcher has no items to process (items array is empty)\n */\n isEmpty: boolean\n /**\n * Whether the batcher is waiting for the timeout to trigger batch processing\n */\n isPending: boolean\n /**\n * Array of items currently queued for batch processing\n */\n items: Array<TValue>\n /**\n * Number of items currently in the batch queue\n */\n size: number\n /**\n * Current processing status - 'idle' when not processing, 'pending' when waiting for timeout\n */\n status: 'idle' | 'pending'\n /**\n * Total number of items that have been processed across all batches\n */\n totalItemsProcessed: number\n}\n\nfunction getDefaultBatcherState<TValue>(): BatcherState<TValue> {\n return {\n executionCount: 0,\n isEmpty: true,\n isPending: false,\n totalItemsProcessed: 0,\n items: [],\n size: 0,\n status: 'idle',\n }\n}\n\n/**\n * Options for configuring a Batcher instance\n */\nexport interface BatcherOptions<TValue> {\n /**\n * Custom function to determine if a batch should be processed\n * Return true to process the batch immediately\n */\n getShouldExecute?: (items: Array<TValue>, batcher: Batcher<TValue>) => boolean\n /**\n * Initial state for the batcher\n */\n initialState?: Partial<BatcherState<TValue>>\n /**\n * Optional key to identify this batcher instance.\n * If provided, the batcher will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of items in a batch\n * @default Infinity\n */\n maxSize?: number\n /**\n * Callback fired after a batch is processed\n */\n onExecute?: (batch: Array<TValue>, batcher: Batcher<TValue>) => void\n /**\n * Callback fired after items are added to the batcher\n */\n onItemsChange?: (batcher: Batcher<TValue>) => void\n /**\n * Whether the batcher should start processing immediately\n * @default true\n */\n started?: boolean\n /**\n * Maximum time in milliseconds to wait before processing a batch.\n * If the wait duration has elapsed, the batch will be processed.\n * If not provided, the batch will not be triggered by a timeout.\n * @default Infinity\n */\n wait?: number | ((batcher: Batcher<TValue>) => number)\n}\n\ntype BatcherOptionsWithOptionalCallbacks<TValue> = OptionalKeys<\n Required<BatcherOptions<TValue>>,\n 'initialState' | 'onExecute' | 'onItemsChange' | 'key'\n>\n\nconst defaultOptions: BatcherOptionsWithOptionalCallbacks<any> = {\n getShouldExecute: () => false,\n maxSize: Infinity,\n started: true,\n wait: Infinity,\n}\n\n/**\n * A class that collects items and processes them in batches.\n *\n * Batching is a technique for grouping multiple operations together to be processed as a single unit.\n * This synchronous version is lighter weight and often all you need - upgrade to AsyncBatcher when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * The Batcher provides a flexible way to implement batching with configurable:\n * - Maximum batch size (number of items per batch)\n * - Time-based batching (process after X milliseconds)\n * - Custom batch processing logic via getShouldExecute\n * - Event callbacks for monitoring batch operations\n *\n * State Management:\n * - Uses TanStack Store for reactive state management\n * - Use `initialState` to provide initial state values when creating the batcher\n * - Use `onExecute` callback to react to batch execution and implement custom logic\n * - Use `onItemsChange` callback to react to items being added or removed from the batcher\n * - The state includes batch execution count, total items processed, items, and running status\n * - State can be accessed via `batcher.store.state` when using the class directly\n * - When using framework adapters (React/Solid), state is accessed from `batcher.state`\n *\n * @example\n * ```ts\n * const batcher = new Batcher<number>(\n * (items) => console.log('Processing batch:', items),\n * {\n * maxSize: 5,\n * wait: 2000,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batcher.addItem(1);\n * batcher.addItem(2);\n * // After 2 seconds or when 5 items are added, whichever comes first,\n * // the batch will be processed\n * // batcher.flush() // manually trigger a batch\n * ```\n */\nexport class Batcher<TValue> {\n readonly store: Store<Readonly<BatcherState<TValue>>> = new Store(\n getDefaultBatcherState<TValue>(),\n )\n key: string | undefined\n options: BatcherOptionsWithOptionalCallbacks<TValue>\n #timeoutId: NodeJS.Timeout | null = null\n\n constructor(\n public fn: (items: Array<TValue>) => void,\n initialOptions: BatcherOptions<TValue>,\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-Batcher', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the batcher options\n */\n setOptions = (newOptions: Partial<BatcherOptions<TValue>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<BatcherState<TValue>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isPending, items } = combinedState\n const size = items.length\n const isEmpty = size === 0\n return {\n ...combinedState,\n isEmpty,\n size,\n status: isPending ? 'pending' : 'idle',\n }\n })\n emitChange('Batcher', this)\n }\n\n #getWait = (): number => {\n return parseFunctionOrValue(this.options.wait, this)\n }\n\n /**\n * Adds an item to the batcher\n * If the batch size is reached, timeout occurs, or shouldProcess returns true, the batch will be processed\n */\n addItem = (item: TValue): void => {\n this.#setState({\n items: [...this.store.state.items, item],\n isPending: this.options.wait !== Infinity,\n })\n this.options.onItemsChange?.(this)\n\n const shouldProcess =\n this.store.state.items.length >= this.options.maxSize ||\n this.options.getShouldExecute(this.store.state.items, this)\n\n if (shouldProcess) {\n this.#execute()\n } else if (this.options.wait !== Infinity) {\n this.#clearTimeout() // clear any pending timeout to replace it with a new one\n this.#timeoutId = setTimeout(() => this.#execute(), this.#getWait())\n }\n }\n\n /**\n * Processes the current batch of items.\n * This method will automatically be triggered if the batcher is running and any of these conditions are met:\n * - The number of items reaches batchSize\n * - The wait duration has elapsed\n * - The getShouldExecute function returns true upon adding an item\n *\n * You can also call this method manually to process the current batch at any time.\n */\n #execute = (): void => {\n if (this.store.state.items.length === 0) {\n return\n }\n\n const batch = this.peekAllItems() // copy of the items to be processed (to prevent race conditions)\n this.clear() // Clear items before processing to prevent race conditions\n this.options.onItemsChange?.(this) // Call onItemsChange to notify listeners that the items have changed\n\n this.fn(batch) // EXECUTE\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n totalItemsProcessed: this.store.state.totalItemsProcessed + batch.length,\n })\n this.options.onExecute?.(batch, this)\n }\n\n /**\n * Processes the current batch of items immediately\n */\n flush = (): void => {\n this.#clearTimeout() // clear any pending timeout\n this.#execute() // execute immediately\n }\n\n /**\n * Returns a copy of all items in the batcher\n */\n peekAllItems = (): Array<TValue> => {\n return [...this.store.state.items]\n }\n\n #clearTimeout = (): void => {\n if (this.#timeoutId) {\n clearTimeout(this.#timeoutId)\n this.#timeoutId = null\n }\n }\n\n /**\n * Removes all items from the batcher\n */\n clear = (): void => {\n this.#setState({ items: [], isPending: false })\n }\n\n /**\n * Cancels any pending execution that was scheduled.\n * Does NOT clear out the items.\n */\n cancel = (): void => {\n this.#clearTimeout()\n this.#setState({ isPending: false })\n }\n\n /**\n * Resets the batcher state to its default values\n */\n reset = (): void => {\n this.#setState(getDefaultBatcherState<TValue>())\n this.options.onItemsChange?.(this)\n }\n}\n\n/**\n * Creates a batcher that processes items in batches.\n *\n * This synchronous version is lighter weight and often all you need - upgrade to asyncBatch when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * @example\n * ```ts\n * const batchItems = batch<number>(\n * (items) => console.log('Processing:', items),\n * {\n * maxSize: 3,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batchItems(1);\n * batchItems(2);\n * batchItems(3); // Triggers batch processing\n * ```\n */\nexport function batch<TValue>(\n fn: (items: Array<TValue>) => void,\n options: BatcherOptions<TValue>,\n) {\n const batcher = new Batcher<TValue>(fn, options)\n return batcher.addItem\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"batcher.cjs","names":["Store","#setState","#execute","#clearTimeout","#timeoutId","#getWait","parseFunctionOrValue"],"sources":["../src/batcher.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { OptionalKeys } from './types'\n\nexport interface BatcherState<TValue> {\n /**\n * Number of batch executions that have been completed\n */\n executionCount: number\n /**\n * Whether the batcher has no items to process (items array is empty)\n */\n isEmpty: boolean\n /**\n * Whether the batcher is waiting for the timeout to trigger batch processing\n */\n isPending: boolean\n /**\n * Array of items currently queued for batch processing\n */\n items: Array<TValue>\n /**\n * Number of items currently in the batch queue\n */\n size: number\n /**\n * Current processing status - 'idle' when not processing, 'pending' when waiting for timeout\n */\n status: 'idle' | 'pending'\n /**\n * Total number of items that have been processed across all batches\n */\n totalItemsProcessed: number\n}\n\nfunction getDefaultBatcherState<TValue>(): BatcherState<TValue> {\n return {\n executionCount: 0,\n isEmpty: true,\n isPending: false,\n totalItemsProcessed: 0,\n items: [],\n size: 0,\n status: 'idle',\n }\n}\n\n/**\n * Options for configuring a Batcher instance\n */\nexport interface BatcherOptions<TValue> {\n /**\n * Custom function to determine if a batch should be processed\n * Return true to process the batch immediately\n */\n getShouldExecute?: (items: Array<TValue>, batcher: Batcher<TValue>) => boolean\n /**\n * Initial state for the batcher\n */\n initialState?: Partial<BatcherState<TValue>>\n /**\n * Optional key to identify this batcher instance.\n * If provided, the batcher will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of items in a batch\n * @default Infinity\n */\n maxSize?: number\n /**\n * Callback fired after a batch is processed\n */\n onExecute?: (batch: Array<TValue>, batcher: Batcher<TValue>) => void\n /**\n * Callback fired after items are added to the batcher\n */\n onItemsChange?: (batcher: Batcher<TValue>) => void\n /**\n * Whether the batcher should start processing immediately\n * @default true\n */\n started?: boolean\n /**\n * Maximum time in milliseconds to wait before processing a batch.\n * If the wait duration has elapsed, the batch will be processed.\n * If not provided, the batch will not be triggered by a timeout.\n * @default Infinity\n */\n wait?: number | ((batcher: Batcher<TValue>) => number)\n}\n\ntype BatcherOptionsWithOptionalCallbacks<TValue> = OptionalKeys<\n Required<BatcherOptions<TValue>>,\n 'initialState' | 'onExecute' | 'onItemsChange' | 'key'\n>\n\nconst defaultOptions: BatcherOptionsWithOptionalCallbacks<any> = {\n getShouldExecute: () => false,\n maxSize: Infinity,\n started: true,\n wait: Infinity,\n}\n\n/**\n * A class that collects items and processes them in batches.\n *\n * Batching is a technique for grouping multiple operations together to be processed as a single unit.\n * This synchronous version is lighter weight and often all you need - upgrade to AsyncBatcher when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * The Batcher provides a flexible way to implement batching with configurable:\n * - Maximum batch size (number of items per batch)\n * - Time-based batching (process after X milliseconds)\n * - Custom batch processing logic via getShouldExecute\n * - Event callbacks for monitoring batch operations\n *\n * State Management:\n * - Uses TanStack Store for reactive state management\n * - Use `initialState` to provide initial state values when creating the batcher\n * - Use `onExecute` callback to react to batch execution and implement custom logic\n * - Use `onItemsChange` callback to react to items being added or removed from the batcher\n * - The state includes batch execution count, total items processed, items, and running status\n * - State can be accessed via `batcher.store.state` when using the class directly\n * - When using framework adapters (React/Solid), state is accessed from `batcher.state`\n *\n * @example\n * ```ts\n * const batcher = new Batcher<number>(\n * (items) => console.log('Processing batch:', items),\n * {\n * maxSize: 5,\n * wait: 2000,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batcher.addItem(1);\n * batcher.addItem(2);\n * // After 2 seconds or when 5 items are added, whichever comes first,\n * // the batch will be processed\n * // batcher.flush() // manually trigger a batch\n * ```\n */\nexport class Batcher<TValue> {\n readonly store: Store<Readonly<BatcherState<TValue>>> = new Store(\n getDefaultBatcherState<TValue>(),\n )\n key: string | undefined\n options: BatcherOptionsWithOptionalCallbacks<TValue>\n #timeoutId: NodeJS.Timeout | null = null\n\n constructor(\n public fn: (items: Array<TValue>) => void,\n initialOptions: BatcherOptions<TValue>,\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-Batcher', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the batcher options\n */\n setOptions = (newOptions: Partial<BatcherOptions<TValue>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<BatcherState<TValue>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isPending, items } = combinedState\n const size = items.length\n const isEmpty = size === 0\n return {\n ...combinedState,\n isEmpty,\n size,\n status: isPending ? 'pending' : 'idle',\n }\n })\n emitChange('Batcher', this)\n }\n\n #getWait = (): number => {\n return parseFunctionOrValue(this.options.wait, this)\n }\n\n /**\n * Adds an item to the batcher\n * If the batch size is reached, timeout occurs, or shouldProcess returns true, the batch will be processed\n */\n addItem = (item: TValue): void => {\n this.#setState({\n items: [...this.store.state.items, item],\n isPending: this.options.wait !== Infinity,\n })\n this.options.onItemsChange?.(this)\n\n const shouldProcess =\n this.store.state.items.length >= this.options.maxSize ||\n this.options.getShouldExecute(this.store.state.items, this)\n\n if (shouldProcess) {\n this.#execute()\n } else if (this.options.wait !== Infinity) {\n this.#clearTimeout() // clear any pending timeout to replace it with a new one\n this.#timeoutId = setTimeout(() => this.#execute(), this.#getWait())\n }\n }\n\n /**\n * Processes the current batch of items.\n * This method will automatically be triggered if the batcher is running and any of these conditions are met:\n * - The number of items reaches batchSize\n * - The wait duration has elapsed\n * - The getShouldExecute function returns true upon adding an item\n *\n * You can also call this method manually to process the current batch at any time.\n */\n #execute = (): void => {\n if (this.store.state.items.length === 0) {\n return\n }\n\n const batch = this.peekAllItems() // copy of the items to be processed (to prevent race conditions)\n this.clear() // Clear items before processing to prevent race conditions\n this.options.onItemsChange?.(this) // Call onItemsChange to notify listeners that the items have changed\n\n this.fn(batch) // EXECUTE\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n totalItemsProcessed: this.store.state.totalItemsProcessed + batch.length,\n })\n this.options.onExecute?.(batch, this)\n }\n\n /**\n * Processes the current batch of items immediately\n */\n flush = (): void => {\n this.#clearTimeout() // clear any pending timeout\n this.#execute() // execute immediately\n }\n\n /**\n * Returns a copy of all items in the batcher\n */\n peekAllItems = (): Array<TValue> => {\n return [...this.store.state.items]\n }\n\n #clearTimeout = (): void => {\n if (this.#timeoutId) {\n clearTimeout(this.#timeoutId)\n this.#timeoutId = null\n }\n }\n\n /**\n * Removes all items from the batcher\n */\n clear = (): void => {\n this.#setState({ items: [], isPending: false })\n }\n\n /**\n * Cancels any pending execution that was scheduled.\n * Does NOT clear out the items.\n */\n cancel = (): void => {\n this.#clearTimeout()\n this.#setState({ isPending: false })\n }\n\n /**\n * Resets the batcher state to its default values\n */\n reset = (): void => {\n this.#setState(getDefaultBatcherState<TValue>())\n this.options.onItemsChange?.(this)\n }\n}\n\n/**\n * Creates a batcher that processes items in batches.\n *\n * This synchronous version is lighter weight and often all you need - upgrade to asyncBatch when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * @example\n * ```ts\n * const batchItems = batch<number>(\n * (items) => console.log('Processing:', items),\n * {\n * maxSize: 3,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batchItems(1);\n * batchItems(2);\n * batchItems(3); // Triggers batch processing\n * ```\n */\nexport function batch<TValue>(\n fn: (items: Array<TValue>) => void,\n options: BatcherOptions<TValue>,\n) {\n const batcher = new Batcher<TValue>(fn, options)\n return batcher.addItem\n}\n"],"mappings":"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"}
|
package/dist/batcher.js
CHANGED
|
@@ -147,15 +147,15 @@ var Batcher = class {
|
|
|
147
147
|
*/
|
|
148
148
|
#execute = () => {
|
|
149
149
|
if (this.store.state.items.length === 0) return;
|
|
150
|
-
const batch
|
|
150
|
+
const batch = this.peekAllItems();
|
|
151
151
|
this.clear();
|
|
152
152
|
this.options.onItemsChange?.(this);
|
|
153
|
-
this.fn(batch
|
|
153
|
+
this.fn(batch);
|
|
154
154
|
this.#setState({
|
|
155
155
|
executionCount: this.store.state.executionCount + 1,
|
|
156
|
-
totalItemsProcessed: this.store.state.totalItemsProcessed + batch
|
|
156
|
+
totalItemsProcessed: this.store.state.totalItemsProcessed + batch.length
|
|
157
157
|
});
|
|
158
|
-
this.options.onExecute?.(batch
|
|
158
|
+
this.options.onExecute?.(batch, this);
|
|
159
159
|
};
|
|
160
160
|
#clearTimeout = () => {
|
|
161
161
|
if (this.#timeoutId) {
|
package/dist/batcher.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"batcher.js","names":["#setState","#execute","#clearTimeout","#timeoutId","#getWait","batch"],"sources":["../src/batcher.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { OptionalKeys } from './types'\n\nexport interface BatcherState<TValue> {\n /**\n * Number of batch executions that have been completed\n */\n executionCount: number\n /**\n * Whether the batcher has no items to process (items array is empty)\n */\n isEmpty: boolean\n /**\n * Whether the batcher is waiting for the timeout to trigger batch processing\n */\n isPending: boolean\n /**\n * Array of items currently queued for batch processing\n */\n items: Array<TValue>\n /**\n * Number of items currently in the batch queue\n */\n size: number\n /**\n * Current processing status - 'idle' when not processing, 'pending' when waiting for timeout\n */\n status: 'idle' | 'pending'\n /**\n * Total number of items that have been processed across all batches\n */\n totalItemsProcessed: number\n}\n\nfunction getDefaultBatcherState<TValue>(): BatcherState<TValue> {\n return {\n executionCount: 0,\n isEmpty: true,\n isPending: false,\n totalItemsProcessed: 0,\n items: [],\n size: 0,\n status: 'idle',\n }\n}\n\n/**\n * Options for configuring a Batcher instance\n */\nexport interface BatcherOptions<TValue> {\n /**\n * Custom function to determine if a batch should be processed\n * Return true to process the batch immediately\n */\n getShouldExecute?: (items: Array<TValue>, batcher: Batcher<TValue>) => boolean\n /**\n * Initial state for the batcher\n */\n initialState?: Partial<BatcherState<TValue>>\n /**\n * Optional key to identify this batcher instance.\n * If provided, the batcher will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of items in a batch\n * @default Infinity\n */\n maxSize?: number\n /**\n * Callback fired after a batch is processed\n */\n onExecute?: (batch: Array<TValue>, batcher: Batcher<TValue>) => void\n /**\n * Callback fired after items are added to the batcher\n */\n onItemsChange?: (batcher: Batcher<TValue>) => void\n /**\n * Whether the batcher should start processing immediately\n * @default true\n */\n started?: boolean\n /**\n * Maximum time in milliseconds to wait before processing a batch.\n * If the wait duration has elapsed, the batch will be processed.\n * If not provided, the batch will not be triggered by a timeout.\n * @default Infinity\n */\n wait?: number | ((batcher: Batcher<TValue>) => number)\n}\n\ntype BatcherOptionsWithOptionalCallbacks<TValue> = OptionalKeys<\n Required<BatcherOptions<TValue>>,\n 'initialState' | 'onExecute' | 'onItemsChange' | 'key'\n>\n\nconst defaultOptions: BatcherOptionsWithOptionalCallbacks<any> = {\n getShouldExecute: () => false,\n maxSize: Infinity,\n started: true,\n wait: Infinity,\n}\n\n/**\n * A class that collects items and processes them in batches.\n *\n * Batching is a technique for grouping multiple operations together to be processed as a single unit.\n * This synchronous version is lighter weight and often all you need - upgrade to AsyncBatcher when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * The Batcher provides a flexible way to implement batching with configurable:\n * - Maximum batch size (number of items per batch)\n * - Time-based batching (process after X milliseconds)\n * - Custom batch processing logic via getShouldExecute\n * - Event callbacks for monitoring batch operations\n *\n * State Management:\n * - Uses TanStack Store for reactive state management\n * - Use `initialState` to provide initial state values when creating the batcher\n * - Use `onExecute` callback to react to batch execution and implement custom logic\n * - Use `onItemsChange` callback to react to items being added or removed from the batcher\n * - The state includes batch execution count, total items processed, items, and running status\n * - State can be accessed via `batcher.store.state` when using the class directly\n * - When using framework adapters (React/Solid), state is accessed from `batcher.state`\n *\n * @example\n * ```ts\n * const batcher = new Batcher<number>(\n * (items) => console.log('Processing batch:', items),\n * {\n * maxSize: 5,\n * wait: 2000,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batcher.addItem(1);\n * batcher.addItem(2);\n * // After 2 seconds or when 5 items are added, whichever comes first,\n * // the batch will be processed\n * // batcher.flush() // manually trigger a batch\n * ```\n */\nexport class Batcher<TValue> {\n readonly store: Store<Readonly<BatcherState<TValue>>> = new Store(\n getDefaultBatcherState<TValue>(),\n )\n key: string | undefined\n options: BatcherOptionsWithOptionalCallbacks<TValue>\n #timeoutId: NodeJS.Timeout | null = null\n\n constructor(\n public fn: (items: Array<TValue>) => void,\n initialOptions: BatcherOptions<TValue>,\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-Batcher', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the batcher options\n */\n setOptions = (newOptions: Partial<BatcherOptions<TValue>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<BatcherState<TValue>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isPending, items } = combinedState\n const size = items.length\n const isEmpty = size === 0\n return {\n ...combinedState,\n isEmpty,\n size,\n status: isPending ? 'pending' : 'idle',\n }\n })\n emitChange('Batcher', this)\n }\n\n #getWait = (): number => {\n return parseFunctionOrValue(this.options.wait, this)\n }\n\n /**\n * Adds an item to the batcher\n * If the batch size is reached, timeout occurs, or shouldProcess returns true, the batch will be processed\n */\n addItem = (item: TValue): void => {\n this.#setState({\n items: [...this.store.state.items, item],\n isPending: this.options.wait !== Infinity,\n })\n this.options.onItemsChange?.(this)\n\n const shouldProcess =\n this.store.state.items.length >= this.options.maxSize ||\n this.options.getShouldExecute(this.store.state.items, this)\n\n if (shouldProcess) {\n this.#execute()\n } else if (this.options.wait !== Infinity) {\n this.#clearTimeout() // clear any pending timeout to replace it with a new one\n this.#timeoutId = setTimeout(() => this.#execute(), this.#getWait())\n }\n }\n\n /**\n * Processes the current batch of items.\n * This method will automatically be triggered if the batcher is running and any of these conditions are met:\n * - The number of items reaches batchSize\n * - The wait duration has elapsed\n * - The getShouldExecute function returns true upon adding an item\n *\n * You can also call this method manually to process the current batch at any time.\n */\n #execute = (): void => {\n if (this.store.state.items.length === 0) {\n return\n }\n\n const batch = this.peekAllItems() // copy of the items to be processed (to prevent race conditions)\n this.clear() // Clear items before processing to prevent race conditions\n this.options.onItemsChange?.(this) // Call onItemsChange to notify listeners that the items have changed\n\n this.fn(batch) // EXECUTE\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n totalItemsProcessed: this.store.state.totalItemsProcessed + batch.length,\n })\n this.options.onExecute?.(batch, this)\n }\n\n /**\n * Processes the current batch of items immediately\n */\n flush = (): void => {\n this.#clearTimeout() // clear any pending timeout\n this.#execute() // execute immediately\n }\n\n /**\n * Returns a copy of all items in the batcher\n */\n peekAllItems = (): Array<TValue> => {\n return [...this.store.state.items]\n }\n\n #clearTimeout = (): void => {\n if (this.#timeoutId) {\n clearTimeout(this.#timeoutId)\n this.#timeoutId = null\n }\n }\n\n /**\n * Removes all items from the batcher\n */\n clear = (): void => {\n this.#setState({ items: [], isPending: false })\n }\n\n /**\n * Cancels any pending execution that was scheduled.\n * Does NOT clear out the items.\n */\n cancel = (): void => {\n this.#clearTimeout()\n this.#setState({ isPending: false })\n }\n\n /**\n * Resets the batcher state to its default values\n */\n reset = (): void => {\n this.#setState(getDefaultBatcherState<TValue>())\n this.options.onItemsChange?.(this)\n }\n}\n\n/**\n * Creates a batcher that processes items in batches.\n *\n * This synchronous version is lighter weight and often all you need - upgrade to asyncBatch when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * @example\n * ```ts\n * const batchItems = batch<number>(\n * (items) => console.log('Processing:', items),\n * {\n * maxSize: 3,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batchItems(1);\n * batchItems(2);\n * batchItems(3); // Triggers batch processing\n * ```\n */\nexport function batch<TValue>(\n fn: (items: Array<TValue>) => void,\n options: BatcherOptions<TValue>,\n) {\n const batcher = new Batcher<TValue>(fn, options)\n return batcher.addItem\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"batcher.js","names":["#setState","#execute","#clearTimeout","#timeoutId","#getWait"],"sources":["../src/batcher.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { parseFunctionOrValue } from './utils'\nimport { emitChange, pacerEventClient } from './event-client'\nimport type { OptionalKeys } from './types'\n\nexport interface BatcherState<TValue> {\n /**\n * Number of batch executions that have been completed\n */\n executionCount: number\n /**\n * Whether the batcher has no items to process (items array is empty)\n */\n isEmpty: boolean\n /**\n * Whether the batcher is waiting for the timeout to trigger batch processing\n */\n isPending: boolean\n /**\n * Array of items currently queued for batch processing\n */\n items: Array<TValue>\n /**\n * Number of items currently in the batch queue\n */\n size: number\n /**\n * Current processing status - 'idle' when not processing, 'pending' when waiting for timeout\n */\n status: 'idle' | 'pending'\n /**\n * Total number of items that have been processed across all batches\n */\n totalItemsProcessed: number\n}\n\nfunction getDefaultBatcherState<TValue>(): BatcherState<TValue> {\n return {\n executionCount: 0,\n isEmpty: true,\n isPending: false,\n totalItemsProcessed: 0,\n items: [],\n size: 0,\n status: 'idle',\n }\n}\n\n/**\n * Options for configuring a Batcher instance\n */\nexport interface BatcherOptions<TValue> {\n /**\n * Custom function to determine if a batch should be processed\n * Return true to process the batch immediately\n */\n getShouldExecute?: (items: Array<TValue>, batcher: Batcher<TValue>) => boolean\n /**\n * Initial state for the batcher\n */\n initialState?: Partial<BatcherState<TValue>>\n /**\n * Optional key to identify this batcher instance.\n * If provided, the batcher will be identified by this key in the devtools and PacerProvider if applicable.\n */\n key?: string\n /**\n * Maximum number of items in a batch\n * @default Infinity\n */\n maxSize?: number\n /**\n * Callback fired after a batch is processed\n */\n onExecute?: (batch: Array<TValue>, batcher: Batcher<TValue>) => void\n /**\n * Callback fired after items are added to the batcher\n */\n onItemsChange?: (batcher: Batcher<TValue>) => void\n /**\n * Whether the batcher should start processing immediately\n * @default true\n */\n started?: boolean\n /**\n * Maximum time in milliseconds to wait before processing a batch.\n * If the wait duration has elapsed, the batch will be processed.\n * If not provided, the batch will not be triggered by a timeout.\n * @default Infinity\n */\n wait?: number | ((batcher: Batcher<TValue>) => number)\n}\n\ntype BatcherOptionsWithOptionalCallbacks<TValue> = OptionalKeys<\n Required<BatcherOptions<TValue>>,\n 'initialState' | 'onExecute' | 'onItemsChange' | 'key'\n>\n\nconst defaultOptions: BatcherOptionsWithOptionalCallbacks<any> = {\n getShouldExecute: () => false,\n maxSize: Infinity,\n started: true,\n wait: Infinity,\n}\n\n/**\n * A class that collects items and processes them in batches.\n *\n * Batching is a technique for grouping multiple operations together to be processed as a single unit.\n * This synchronous version is lighter weight and often all you need - upgrade to AsyncBatcher when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * The Batcher provides a flexible way to implement batching with configurable:\n * - Maximum batch size (number of items per batch)\n * - Time-based batching (process after X milliseconds)\n * - Custom batch processing logic via getShouldExecute\n * - Event callbacks for monitoring batch operations\n *\n * State Management:\n * - Uses TanStack Store for reactive state management\n * - Use `initialState` to provide initial state values when creating the batcher\n * - Use `onExecute` callback to react to batch execution and implement custom logic\n * - Use `onItemsChange` callback to react to items being added or removed from the batcher\n * - The state includes batch execution count, total items processed, items, and running status\n * - State can be accessed via `batcher.store.state` when using the class directly\n * - When using framework adapters (React/Solid), state is accessed from `batcher.state`\n *\n * @example\n * ```ts\n * const batcher = new Batcher<number>(\n * (items) => console.log('Processing batch:', items),\n * {\n * maxSize: 5,\n * wait: 2000,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batcher.addItem(1);\n * batcher.addItem(2);\n * // After 2 seconds or when 5 items are added, whichever comes first,\n * // the batch will be processed\n * // batcher.flush() // manually trigger a batch\n * ```\n */\nexport class Batcher<TValue> {\n readonly store: Store<Readonly<BatcherState<TValue>>> = new Store(\n getDefaultBatcherState<TValue>(),\n )\n key: string | undefined\n options: BatcherOptionsWithOptionalCallbacks<TValue>\n #timeoutId: NodeJS.Timeout | null = null\n\n constructor(\n public fn: (items: Array<TValue>) => void,\n initialOptions: BatcherOptions<TValue>,\n ) {\n this.key = initialOptions.key\n this.options = {\n ...defaultOptions,\n ...initialOptions,\n }\n this.#setState(this.options.initialState ?? {})\n\n if (this.key) {\n pacerEventClient.on('d-Batcher', (event) => {\n if (event.payload.key !== this.key) return\n this.#setState(event.payload.store.state)\n this.setOptions(event.payload.options)\n })\n }\n }\n\n /**\n * Updates the batcher options\n */\n setOptions = (newOptions: Partial<BatcherOptions<TValue>>): void => {\n this.options = { ...this.options, ...newOptions }\n }\n\n #setState = (newState: Partial<BatcherState<TValue>>): void => {\n this.store.setState((state) => {\n const combinedState = {\n ...state,\n ...newState,\n }\n const { isPending, items } = combinedState\n const size = items.length\n const isEmpty = size === 0\n return {\n ...combinedState,\n isEmpty,\n size,\n status: isPending ? 'pending' : 'idle',\n }\n })\n emitChange('Batcher', this)\n }\n\n #getWait = (): number => {\n return parseFunctionOrValue(this.options.wait, this)\n }\n\n /**\n * Adds an item to the batcher\n * If the batch size is reached, timeout occurs, or shouldProcess returns true, the batch will be processed\n */\n addItem = (item: TValue): void => {\n this.#setState({\n items: [...this.store.state.items, item],\n isPending: this.options.wait !== Infinity,\n })\n this.options.onItemsChange?.(this)\n\n const shouldProcess =\n this.store.state.items.length >= this.options.maxSize ||\n this.options.getShouldExecute(this.store.state.items, this)\n\n if (shouldProcess) {\n this.#execute()\n } else if (this.options.wait !== Infinity) {\n this.#clearTimeout() // clear any pending timeout to replace it with a new one\n this.#timeoutId = setTimeout(() => this.#execute(), this.#getWait())\n }\n }\n\n /**\n * Processes the current batch of items.\n * This method will automatically be triggered if the batcher is running and any of these conditions are met:\n * - The number of items reaches batchSize\n * - The wait duration has elapsed\n * - The getShouldExecute function returns true upon adding an item\n *\n * You can also call this method manually to process the current batch at any time.\n */\n #execute = (): void => {\n if (this.store.state.items.length === 0) {\n return\n }\n\n const batch = this.peekAllItems() // copy of the items to be processed (to prevent race conditions)\n this.clear() // Clear items before processing to prevent race conditions\n this.options.onItemsChange?.(this) // Call onItemsChange to notify listeners that the items have changed\n\n this.fn(batch) // EXECUTE\n this.#setState({\n executionCount: this.store.state.executionCount + 1,\n totalItemsProcessed: this.store.state.totalItemsProcessed + batch.length,\n })\n this.options.onExecute?.(batch, this)\n }\n\n /**\n * Processes the current batch of items immediately\n */\n flush = (): void => {\n this.#clearTimeout() // clear any pending timeout\n this.#execute() // execute immediately\n }\n\n /**\n * Returns a copy of all items in the batcher\n */\n peekAllItems = (): Array<TValue> => {\n return [...this.store.state.items]\n }\n\n #clearTimeout = (): void => {\n if (this.#timeoutId) {\n clearTimeout(this.#timeoutId)\n this.#timeoutId = null\n }\n }\n\n /**\n * Removes all items from the batcher\n */\n clear = (): void => {\n this.#setState({ items: [], isPending: false })\n }\n\n /**\n * Cancels any pending execution that was scheduled.\n * Does NOT clear out the items.\n */\n cancel = (): void => {\n this.#clearTimeout()\n this.#setState({ isPending: false })\n }\n\n /**\n * Resets the batcher state to its default values\n */\n reset = (): void => {\n this.#setState(getDefaultBatcherState<TValue>())\n this.options.onItemsChange?.(this)\n }\n}\n\n/**\n * Creates a batcher that processes items in batches.\n *\n * This synchronous version is lighter weight and often all you need - upgrade to asyncBatch when you need promises, retry support, abort/cancel capabilities, or advanced error handling.\n *\n * @example\n * ```ts\n * const batchItems = batch<number>(\n * (items) => console.log('Processing:', items),\n * {\n * maxSize: 3,\n * onExecute: (batch, batcher) => console.log('Batch executed:', batch)\n * }\n * );\n *\n * batchItems(1);\n * batchItems(2);\n * batchItems(3); // Triggers batch processing\n * ```\n */\nexport function batch<TValue>(\n fn: (items: Array<TValue>) => void,\n options: BatcherOptions<TValue>,\n) {\n const batcher = new Batcher<TValue>(fn, options)\n return batcher.addItem\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/async-retryer.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface AsyncRetryerState<TFn extends AnyAsyncFunction> {
|
|
|
45
45
|
function getDefaultAsyncRetryerState<
|
|
46
46
|
TFn extends AnyAsyncFunction,
|
|
47
47
|
>(): AsyncRetryerState<TFn> {
|
|
48
|
-
return
|
|
48
|
+
return {
|
|
49
49
|
currentAttempt: 0,
|
|
50
50
|
executionCount: 0,
|
|
51
51
|
isExecuting: false,
|
|
@@ -54,7 +54,7 @@ function getDefaultAsyncRetryerState<
|
|
|
54
54
|
lastResult: undefined,
|
|
55
55
|
status: 'idle',
|
|
56
56
|
totalExecutionTime: 0,
|
|
57
|
-
}
|
|
57
|
+
}
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
export interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
@@ -105,6 +105,11 @@ export interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
105
105
|
* @default Infinity
|
|
106
106
|
*/
|
|
107
107
|
maxTotalExecutionTime?: number
|
|
108
|
+
/**
|
|
109
|
+
* Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time
|
|
110
|
+
* @default Infinity
|
|
111
|
+
*/
|
|
112
|
+
maxWait?: number | ((retryer: AsyncRetryer<TFn>) => number)
|
|
108
113
|
/**
|
|
109
114
|
* Callback invoked when the execution is aborted (manually or due to timeouts)
|
|
110
115
|
*/
|
|
@@ -120,6 +125,10 @@ export interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
120
125
|
args: Parameters<TFn>,
|
|
121
126
|
retryer: AsyncRetryer<TFn>,
|
|
122
127
|
) => void
|
|
128
|
+
/**
|
|
129
|
+
* Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
|
|
130
|
+
*/
|
|
131
|
+
onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void
|
|
123
132
|
/**
|
|
124
133
|
* Callback invoked when the final error occurs after all retries are exhausted
|
|
125
134
|
*/
|
|
@@ -140,10 +149,6 @@ export interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
|
|
|
140
149
|
args: Parameters<TFn>,
|
|
141
150
|
retryer: AsyncRetryer<TFn>,
|
|
142
151
|
) => void
|
|
143
|
-
/**
|
|
144
|
-
* Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
|
|
145
|
-
*/
|
|
146
|
-
onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void
|
|
147
152
|
/**
|
|
148
153
|
* Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)
|
|
149
154
|
*/
|
|
@@ -185,6 +190,7 @@ const defaultOptions: Omit<
|
|
|
185
190
|
> = {
|
|
186
191
|
backoff: 'exponential',
|
|
187
192
|
baseWait: 1000,
|
|
193
|
+
maxWait: Infinity,
|
|
188
194
|
enabled: true,
|
|
189
195
|
jitter: 0,
|
|
190
196
|
maxAttempts: 3,
|
|
@@ -209,6 +215,8 @@ const defaultOptions: Omit<
|
|
|
209
215
|
* - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt
|
|
210
216
|
* - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).
|
|
211
217
|
* Set to a value between 0-1 to apply that percentage of random variation to each delay.
|
|
218
|
+
* - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).
|
|
219
|
+
* Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).
|
|
212
220
|
* - **Timeout Controls**: Set limits on execution time to prevent hanging operations:
|
|
213
221
|
* - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)
|
|
214
222
|
* - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)
|
|
@@ -249,7 +257,7 @@ const defaultOptions: Omit<
|
|
|
249
257
|
* ## Usage
|
|
250
258
|
*
|
|
251
259
|
* - Use for async operations that may fail transiently and benefit from retrying.
|
|
252
|
-
* - Configure `maxAttempts`, `backoff`, `baseWait`, and `jitter` to control retry behavior.
|
|
260
|
+
* - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.
|
|
253
261
|
* - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.
|
|
254
262
|
* - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.
|
|
255
263
|
* - Call `abort()` to cancel ongoing execution and pending retries.
|
|
@@ -362,6 +370,10 @@ export class AsyncRetryer<TFn extends AnyAsyncFunction> {
|
|
|
362
370
|
return parseFunctionOrValue(this.options.baseWait, this)
|
|
363
371
|
}
|
|
364
372
|
|
|
373
|
+
#getMaxWait = (): number => {
|
|
374
|
+
return parseFunctionOrValue(this.options.maxWait, this)
|
|
375
|
+
}
|
|
376
|
+
|
|
365
377
|
#calculateJitter = (waitTime: number): number => {
|
|
366
378
|
const jitterAmount = this.options.jitter
|
|
367
379
|
if (jitterAmount <= 0) return 0
|
|
@@ -399,6 +411,8 @@ export class AsyncRetryer<TFn extends AnyAsyncFunction> {
|
|
|
399
411
|
break
|
|
400
412
|
}
|
|
401
413
|
|
|
414
|
+
waitTime = Math.min(waitTime, this.#getMaxWait())
|
|
415
|
+
|
|
402
416
|
const jitter = this.#calculateJitter(waitTime)
|
|
403
417
|
return Math.max(0, waitTime + jitter)
|
|
404
418
|
}
|