@tanstack/pacer 0.22.0 → 0.23.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.
Files changed (100) hide show
  1. package/README.md +4 -4
  2. package/dist/async-batcher.d.ts +11 -13
  3. package/dist/async-batcher.js +185 -131
  4. package/dist/async-debouncer.d.ts +6 -8
  5. package/dist/async-debouncer.js +186 -142
  6. package/dist/async-queuer.d.ts +11 -13
  7. package/dist/async-queuer.js +383 -290
  8. package/dist/async-rate-limiter.d.ts +6 -8
  9. package/dist/async-rate-limiter.js +197 -143
  10. package/dist/async-retryer.d.ts +6 -8
  11. package/dist/async-retryer.js +209 -179
  12. package/dist/async-throttler.d.ts +6 -8
  13. package/dist/async-throttler.js +214 -157
  14. package/dist/batcher.d.ts +5 -7
  15. package/dist/batcher.js +107 -87
  16. package/dist/debouncer.d.ts +6 -8
  17. package/dist/debouncer.js +99 -86
  18. package/dist/event-client.d.ts +8 -10
  19. package/dist/event-client.js +1 -2
  20. package/dist/queuer.d.ts +7 -9
  21. package/dist/queuer.js +277 -212
  22. package/dist/rate-limiter.d.ts +6 -8
  23. package/dist/rate-limiter.js +127 -109
  24. package/dist/throttler.d.ts +6 -8
  25. package/dist/throttler.js +127 -90
  26. package/dist/types.d.ts +4 -6
  27. package/dist/utils.d.ts +3 -5
  28. package/dist/utils.js +1 -2
  29. package/package.json +21 -68
  30. package/dist/async-batcher.cjs +0 -337
  31. package/dist/async-batcher.cjs.map +0 -1
  32. package/dist/async-batcher.d.cts +0 -343
  33. package/dist/async-batcher.js.map +0 -1
  34. package/dist/async-debouncer.cjs +0 -327
  35. package/dist/async-debouncer.cjs.map +0 -1
  36. package/dist/async-debouncer.d.cts +0 -299
  37. package/dist/async-debouncer.js.map +0 -1
  38. package/dist/async-queuer.cjs +0 -516
  39. package/dist/async-queuer.cjs.map +0 -1
  40. package/dist/async-queuer.d.cts +0 -440
  41. package/dist/async-queuer.js.map +0 -1
  42. package/dist/async-rate-limiter.cjs +0 -370
  43. package/dist/async-rate-limiter.cjs.map +0 -1
  44. package/dist/async-rate-limiter.d.cts +0 -356
  45. package/dist/async-rate-limiter.js.map +0 -1
  46. package/dist/async-retryer.cjs +0 -365
  47. package/dist/async-retryer.cjs.map +0 -1
  48. package/dist/async-retryer.d.cts +0 -321
  49. package/dist/async-retryer.js.map +0 -1
  50. package/dist/async-throttler.cjs +0 -344
  51. package/dist/async-throttler.cjs.map +0 -1
  52. package/dist/async-throttler.d.cts +0 -319
  53. package/dist/async-throttler.js.map +0 -1
  54. package/dist/batcher.cjs +0 -200
  55. package/dist/batcher.cjs.map +0 -1
  56. package/dist/batcher.d.cts +0 -179
  57. package/dist/batcher.js.map +0 -1
  58. package/dist/debouncer.cjs +0 -203
  59. package/dist/debouncer.cjs.map +0 -1
  60. package/dist/debouncer.d.cts +0 -166
  61. package/dist/debouncer.js.map +0 -1
  62. package/dist/event-client.cjs +0 -64
  63. package/dist/event-client.cjs.map +0 -1
  64. package/dist/event-client.d.cts +0 -65
  65. package/dist/event-client.js.map +0 -1
  66. package/dist/index.cjs +0 -52
  67. package/dist/index.d.cts +0 -15
  68. package/dist/queuer.cjs +0 -406
  69. package/dist/queuer.cjs.map +0 -1
  70. package/dist/queuer.d.cts +0 -345
  71. package/dist/queuer.js.map +0 -1
  72. package/dist/rate-limiter.cjs +0 -263
  73. package/dist/rate-limiter.cjs.map +0 -1
  74. package/dist/rate-limiter.d.cts +0 -214
  75. package/dist/rate-limiter.js.map +0 -1
  76. package/dist/throttler.cjs +0 -215
  77. package/dist/throttler.cjs.map +0 -1
  78. package/dist/throttler.d.cts +0 -206
  79. package/dist/throttler.js.map +0 -1
  80. package/dist/types.cjs +0 -0
  81. package/dist/types.d.cts +0 -13
  82. package/dist/utils.cjs +0 -13
  83. package/dist/utils.cjs.map +0 -1
  84. package/dist/utils.d.cts +0 -7
  85. package/dist/utils.js.map +0 -1
  86. package/src/async-batcher.ts +0 -594
  87. package/src/async-debouncer.ts +0 -566
  88. package/src/async-queuer.ts +0 -988
  89. package/src/async-rate-limiter.ts +0 -648
  90. package/src/async-retryer.ts +0 -673
  91. package/src/async-throttler.ts +0 -634
  92. package/src/batcher.ts +0 -329
  93. package/src/debouncer.ts +0 -334
  94. package/src/event-client.ts +0 -129
  95. package/src/index.ts +0 -24
  96. package/src/queuer.ts +0 -751
  97. package/src/rate-limiter.ts +0 -429
  98. package/src/throttler.ts +0 -380
  99. package/src/types.ts +0 -12
  100. package/src/utils.ts +0 -12
@@ -1,673 +0,0 @@
1
- import { Store } from '@tanstack/store'
2
- import { parseFunctionOrValue } from './utils'
3
- import type { AnyAsyncFunction } from './types'
4
-
5
- export interface AsyncRetryerState<TFn extends AnyAsyncFunction> {
6
- /**
7
- * The current retry attempt number (0 when not executing)
8
- */
9
- currentAttempt: number
10
- /**
11
- * Total number of completed executions (successful or failed)
12
- */
13
- executionCount: number
14
- /**
15
- * Whether the retryer is currently executing the function
16
- */
17
- isExecuting: boolean
18
- /**
19
- * The most recent error encountered during execution
20
- */
21
- lastError: Error | undefined
22
- /**
23
- * Timestamp of the last execution completion in milliseconds
24
- */
25
- lastExecutionTime: number
26
- /**
27
- * The result from the most recent successful execution
28
- */
29
- lastResult: Awaited<ReturnType<TFn>> | undefined
30
- /**
31
- * Current execution status - 'disabled' when not enabled, 'idle' when ready, 'executing' when running
32
- */
33
- status: 'disabled' | 'idle' | 'executing' | 'retrying'
34
- /**
35
- * Total time spent executing (including retries) in milliseconds
36
- */
37
- totalExecutionTime: number
38
- }
39
-
40
- /**
41
- * Creates the default initial state for an AsyncRetryer instance
42
- * @returns The default state with all values reset to initial values
43
- */
44
- function getDefaultAsyncRetryerState<
45
- TFn extends AnyAsyncFunction,
46
- >(): AsyncRetryerState<TFn> {
47
- return {
48
- currentAttempt: 0,
49
- executionCount: 0,
50
- isExecuting: false,
51
- lastError: undefined,
52
- lastExecutionTime: 0,
53
- lastResult: undefined,
54
- status: 'idle',
55
- totalExecutionTime: 0,
56
- }
57
- }
58
-
59
- export interface AsyncRetryerOptions<TFn extends AnyAsyncFunction> {
60
- /**
61
- * The backoff strategy for retry delays:
62
- * - 'exponential': Wait time doubles with each attempt (1s, 2s, 4s, ...)
63
- * - 'linear': Wait time increases linearly (1s, 2s, 3s, ...)
64
- * - 'fixed': Same wait time for all attempts
65
- * @default 'exponential'
66
- */
67
- backoff?: 'linear' | 'exponential' | 'fixed'
68
- /**
69
- * Base wait time in milliseconds between retries, or a function that returns the wait time
70
- * @default 1000
71
- */
72
- baseWait?: number | ((retryer: AsyncRetryer<TFn>) => number)
73
- /**
74
- * Whether the retryer is enabled, or a function that determines if it's enabled
75
- * @default true
76
- */
77
- enabled?: boolean | ((retryer: AsyncRetryer<TFn>) => boolean)
78
- /**
79
- * Initial state to merge with the default state
80
- */
81
- initialState?: Partial<AsyncRetryerState<TFn>>
82
- /**
83
- * Jitter percentage to add to retry delays (0-1). Adds randomness to prevent thundering herd.
84
- * @default 0
85
- */
86
- jitter?: number
87
- /**
88
- * Optional key to identify this async retryer instance.
89
- * Note: async retryers are not currently surfaced in the devtools, so this key
90
- * is only a plain identifier. Retryer instances are often created per-execution
91
- * (including internally by the other async utilities), so they intentionally do
92
- * not register with the devtools event bus.
93
- */
94
- key?: string
95
- /**
96
- * Maximum number of retry attempts, or a function that returns the max attempts
97
- * @default 3
98
- */
99
- maxAttempts?: number | ((retryer: AsyncRetryer<TFn>) => number)
100
- /**
101
- * Maximum execution time in milliseconds for a single function call before aborting
102
- * @default Infinity
103
- */
104
- maxExecutionTime?: number
105
- /**
106
- * Maximum total execution time in milliseconds for the entire retry operation before aborting
107
- * @default Infinity
108
- */
109
- maxTotalExecutionTime?: number
110
- /**
111
- * Maximum wait time in milliseconds to cap retry delays, or a function that returns the max wait time
112
- * @default Infinity
113
- */
114
- maxWait?: number | ((retryer: AsyncRetryer<TFn>) => number)
115
- /**
116
- * Callback invoked when the execution is aborted (manually or due to timeouts)
117
- */
118
- onAbort?: (
119
- reason: 'manual' | 'execution-timeout' | 'total-timeout' | 'new-execution',
120
- retryer: AsyncRetryer<TFn>,
121
- ) => void
122
- /**
123
- * Callback invoked when any error occurs during execution (including retries)
124
- */
125
- onError?: (
126
- error: Error,
127
- args: Parameters<TFn>,
128
- retryer: AsyncRetryer<TFn>,
129
- ) => void
130
- /**
131
- * Callback invoked when a single execution attempt times out (maxExecutionTime exceeded)
132
- */
133
- onExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void
134
- /**
135
- * Callback invoked when the final error occurs after all retries are exhausted
136
- */
137
- onLastError?: (error: Error, retryer: AsyncRetryer<TFn>) => void
138
- /**
139
- * Callback invoked before each retry attempt
140
- */
141
- onRetry?: (attempt: number, error: Error, retryer: AsyncRetryer<TFn>) => void
142
- /**
143
- * Callback invoked after execution completes (success or failure) of each attempt
144
- */
145
- onSettled?: (args: Parameters<TFn>, retryer: AsyncRetryer<TFn>) => void
146
- /**
147
- * Callback invoked when execution succeeds
148
- */
149
- onSuccess?: (
150
- result: Awaited<ReturnType<TFn>>,
151
- args: Parameters<TFn>,
152
- retryer: AsyncRetryer<TFn>,
153
- ) => void
154
- /**
155
- * Callback invoked when the total execution time times out (maxTotalExecutionTime exceeded)
156
- */
157
- onTotalExecutionTimeout?: (retryer: AsyncRetryer<TFn>) => void
158
- /**
159
- * Controls when errors are thrown:
160
- * - 'last': Only throw the final error after all retries are exhausted
161
- * - true: Throw every error immediately (disables retrying)
162
- * - false: Never throw errors, return undefined instead
163
- * @default 'last'
164
- */
165
- throwOnError?: boolean | 'last'
166
- }
167
-
168
- /**
169
- * Utility function for sharing common `AsyncRetryerOptions` options between different `AsyncRetryer` instances.
170
- */
171
- export function asyncRetryerOptions<
172
- TFn extends AnyAsyncFunction = AnyAsyncFunction,
173
- TOptions extends Partial<AsyncRetryerOptions<TFn>> = Partial<
174
- AsyncRetryerOptions<TFn>
175
- >,
176
- >(options: TOptions): TOptions {
177
- return options
178
- }
179
-
180
- const defaultOptions: Omit<
181
- Required<AsyncRetryerOptions<any>>,
182
- | 'initialState'
183
- | 'key'
184
- | 'onAbort'
185
- | 'onError'
186
- | 'onLastError'
187
- | 'onRetry'
188
- | 'onSettled'
189
- | 'onSuccess'
190
- | 'onExecutionTimeout'
191
- | 'onTotalExecutionTimeout'
192
- > = {
193
- backoff: 'exponential',
194
- baseWait: 1000,
195
- maxWait: Infinity,
196
- enabled: true,
197
- jitter: 0,
198
- maxAttempts: 3,
199
- maxExecutionTime: Infinity,
200
- maxTotalExecutionTime: Infinity,
201
- throwOnError: 'last',
202
- }
203
-
204
- /**
205
- * Provides robust retry functionality for asynchronous functions, supporting configurable backoff strategies,
206
- * attempt limits, timeout controls, and detailed state management. The AsyncRetryer class is designed to help you reliably
207
- * execute async operations that may fail intermittently, such as network requests or database operations,
208
- * by automatically retrying them according to your chosen policy.
209
- *
210
- * ## Retrying Concepts
211
- *
212
- * - **Retrying**: Automatically re-executes a failed async function up to a specified number of attempts.
213
- * Useful for handling transient errors (e.g., network flakiness, rate limits, temporary server issues).
214
- * - **Backoff Strategies**: Controls the delay between retry attempts (default: `'exponential'`):
215
- * - `'exponential'`: Wait time doubles with each attempt (1s, 2s, 4s, ...) - **DEFAULT**
216
- * - `'linear'`: Wait time increases linearly (1s, 2s, 3s, ...)
217
- * - `'fixed'`: Waits a constant amount of time (`baseWait`) between each attempt
218
- * - **Jitter**: Adds randomness to retry delays to prevent thundering herd problems (default: `0`).
219
- * Set to a value between 0-1 to apply that percentage of random variation to each delay.
220
- * - **Max Wait**: Caps the maximum wait time between retries (default: `Infinity`).
221
- * Useful for preventing exponential backoff from growing too large (e.g., cap at 30s even if exponential would be 64s).
222
- * - **Timeout Controls**: Set limits on execution time to prevent hanging operations:
223
- * - `maxExecutionTime`: Maximum time for a single function call (default: `Infinity`)
224
- * - `maxTotalExecutionTime`: Maximum time for the entire retry operation (default: `Infinity`)
225
- * - **Abort & Cancellation**: Supports cancellation via an internal `AbortController`. Call `abort()` to stop retries.
226
- * Use `getAbortSignal()` to make your async function actually cancellable (e.g., with fetch requests).
227
- *
228
- * ## State Management
229
- *
230
- * Uses TanStack Store for fine-grained reactivity. State can be accessed via the `store.state` property.
231
- *
232
- * Available state properties:
233
- * - `currentAttempt`: The current retry attempt number (0 when not executing)
234
- * - `executionCount`: Total number of completed executions (successful or failed)
235
- * - `isExecuting`: Whether the retryer is currently executing the function
236
- * - `lastError`: The most recent error encountered during execution
237
- * - `lastExecutionTime`: Timestamp of the last execution completion in milliseconds
238
- * - `lastResult`: The result from the most recent successful execution
239
- * - `status`: Current execution status ('disabled' | 'idle' | 'executing' | 'retrying')
240
- * - `totalExecutionTime`: Total time spent executing (including retries) in milliseconds
241
- *
242
- * ## Error Handling
243
- *
244
- * The `throwOnError` option controls when errors are thrown (default: `'last'`):
245
- * - `'last'`: Only throws the final error after all retries are exhausted - **DEFAULT**
246
- * - `true`: Throws every error immediately (disables retrying)
247
- * - `false`: Never throws errors, returns `undefined` instead
248
- *
249
- * Callbacks for lifecycle management:
250
- * - `onAbort`: Called when execution is aborted (manually or due to timeouts)
251
- * - `onError`: Called for every error (including during retries)
252
- * - `onLastError`: Called only for the final error after all retries fail
253
- * - `onRetry`: Called before each retry attempt
254
- * - `onSettled`: Called after execution completes (success or failure) of each attempt
255
- * - `onSuccess`: Called when execution succeeds
256
- * - `onExecutionTimeout`: Called when a single execution attempt times out
257
- * - `onTotalExecutionTimeout`: Called when the total execution time times out
258
- *
259
- * ## Usage
260
- *
261
- * - Use for async operations that may fail transiently and benefit from retrying.
262
- * - Configure `maxAttempts`, `backoff`, `baseWait`, `maxWait`, and `jitter` to control retry behavior.
263
- * - Set `maxExecutionTime` and `maxTotalExecutionTime` to prevent hanging operations.
264
- * - Use `onAbort`, `onError`, `onLastError`, `onRetry`, `onSettled`, `onSuccess`, `onExecutionTimeout`, and `onTotalExecutionTimeout` for custom side effects.
265
- * - Call `abort()` to cancel ongoing execution and pending retries.
266
- * - Call `reset()` to reset state and cancel execution.
267
- * - Use `getAbortSignal()` to make your async function cancellable.
268
- * - Use dynamic options (functions) for `maxAttempts`, `baseWait`, and `enabled` based on retryer state.
269
- *
270
- * **Important:** This class is designed for single-use execution. Calling `execute()` multiple times
271
- * on the same instance will abort previous executions. For multiple calls, create a new instance
272
- * each time.
273
- *
274
- * @example
275
- * ```typescript
276
- * // Retry a fetch operation up to 5 times with exponential backoff, jitter, and timeouts
277
- * const retryer = new AsyncRetryer(async (url: string) => {
278
- * const signal = retryer.getAbortSignal()
279
- * return await fetch(url, { signal })
280
- * }, {
281
- * maxAttempts: 5,
282
- * backoff: 'exponential',
283
- * baseWait: 1000,
284
- * jitter: 0.1, // Add 10% random variation to prevent thundering herd
285
- * maxExecutionTime: 5000, // Abort individual calls after 5 seconds
286
- * maxTotalExecutionTime: 30000, // Abort entire operation after 30 seconds
287
- * onRetry: (attempt, error) => console.log(`Retry attempt ${attempt} after error:`, error),
288
- * onSuccess: (result) => console.log('Success:', result),
289
- * onError: (error) => console.error('Error:', error),
290
- * onLastError: (error) => console.error('All retries failed:', error),
291
- * })
292
- *
293
- * const result = await retryer.execute('/api/data')
294
- * ```
295
- *
296
- * @template TFn The async function type to be retried.
297
- */
298
- export class AsyncRetryer<TFn extends AnyAsyncFunction> {
299
- readonly store: Store<Readonly<AsyncRetryerState<TFn>>> = new Store(
300
- getDefaultAsyncRetryerState<TFn>(),
301
- )
302
- key: string | undefined
303
- options: AsyncRetryerOptions<TFn> & typeof defaultOptions
304
- #abortController: AbortController | null = null
305
-
306
- /**
307
- * Creates a new AsyncRetryer instance
308
- * @param fn The async function to retry
309
- * @param initialOptions Configuration options for the retryer
310
- */
311
- constructor(
312
- public fn: TFn,
313
- initialOptions: AsyncRetryerOptions<TFn> = {},
314
- ) {
315
- this.key = initialOptions.key
316
- this.options = {
317
- ...defaultOptions,
318
- ...initialOptions,
319
- throwOnError:
320
- initialOptions.throwOnError ??
321
- (initialOptions.onError ? false : defaultOptions.throwOnError),
322
- }
323
- this.#setState(this.options.initialState ?? {})
324
- }
325
-
326
- /**
327
- * Updates the retryer options
328
- * @param newOptions Partial options to merge with existing options
329
- */
330
- setOptions = (newOptions: Partial<AsyncRetryerOptions<TFn>>): void => {
331
- this.options = { ...this.options, ...newOptions }
332
- }
333
-
334
- #setState = (newState: Partial<AsyncRetryerState<TFn>>): void => {
335
- this.store.setState((state) => {
336
- const combinedState = {
337
- ...state,
338
- ...newState,
339
- }
340
- const { isExecuting, currentAttempt } = combinedState
341
- return {
342
- ...combinedState,
343
- status: !this.#getEnabled()
344
- ? 'disabled'
345
- : isExecuting && currentAttempt === 1
346
- ? 'executing'
347
- : isExecuting && currentAttempt > 1
348
- ? 'retrying'
349
- : 'idle',
350
- }
351
- })
352
- }
353
-
354
- #getEnabled = (): boolean => {
355
- return !!parseFunctionOrValue(this.options.enabled, this)
356
- }
357
-
358
- #getMaxAttempts = (): number => {
359
- return parseFunctionOrValue(this.options.maxAttempts, this)
360
- }
361
-
362
- #getBaseWait = (): number => {
363
- return parseFunctionOrValue(this.options.baseWait, this)
364
- }
365
-
366
- #getMaxWait = (): number => {
367
- return parseFunctionOrValue(this.options.maxWait, this)
368
- }
369
-
370
- #calculateJitter = (waitTime: number): number => {
371
- const jitterAmount = this.options.jitter
372
- if (jitterAmount <= 0) return 0
373
-
374
- try {
375
- const crypto =
376
- typeof globalThis !== 'undefined' ? globalThis.crypto : undefined
377
- if (crypto?.getRandomValues) {
378
- const array = new Uint32Array(1)
379
- crypto.getRandomValues(array)
380
- // Convert to 0-1 range and apply jitter percentage
381
- const randomFactor = (array[0]! / 0xffffffff) * 2 - 1 // -1 to 1
382
- return Math.floor(waitTime * jitterAmount * randomFactor)
383
- }
384
- } catch {
385
- // No crypto available
386
- }
387
- return 0
388
- }
389
-
390
- #calculateWait = (attempt: number): number => {
391
- const baseWait = this.#getBaseWait()
392
- let waitTime: number
393
-
394
- switch (this.options.backoff) {
395
- case 'linear':
396
- waitTime = baseWait * attempt
397
- break
398
- case 'exponential':
399
- waitTime = baseWait * Math.pow(2, attempt - 1)
400
- break
401
- case 'fixed':
402
- default:
403
- waitTime = baseWait
404
- break
405
- }
406
-
407
- waitTime = Math.min(waitTime, this.#getMaxWait())
408
-
409
- const jitter = this.#calculateJitter(waitTime)
410
- return Math.max(0, waitTime + jitter)
411
- }
412
-
413
- /**
414
- * Executes the function with retry logic
415
- * @param args Arguments to pass to the function
416
- * @returns The function result, or undefined if disabled or all retries failed (when throwOnError is false)
417
- * @throws The last error if throwOnError is true and all retries fail
418
- */
419
- execute = async (
420
- ...args: Parameters<TFn>
421
- ): Promise<Awaited<ReturnType<TFn>> | undefined> => {
422
- if (!this.#getEnabled()) {
423
- return undefined
424
- }
425
-
426
- // Cancel any existing execution
427
- this.abort('new-execution')
428
-
429
- const startTime = Date.now()
430
- let lastError: Error | undefined
431
- let result: Awaited<ReturnType<TFn>> | undefined
432
-
433
- this.#abortController = new AbortController()
434
- const signal = this.#abortController.signal
435
-
436
- this.#setState({
437
- isExecuting: true,
438
- currentAttempt: 0,
439
- lastError: undefined,
440
- })
441
-
442
- // Set up total execution timeout
443
- let totalTimeoutId: ReturnType<typeof setTimeout> | undefined
444
- if (this.options.maxTotalExecutionTime !== Infinity) {
445
- totalTimeoutId = setTimeout(() => {
446
- this.options.onTotalExecutionTimeout?.(this)
447
- this.abort('total-timeout')
448
- }, this.options.maxTotalExecutionTime)
449
- }
450
-
451
- let isLastAttempt = false
452
- for (let attempt = 1; attempt <= this.#getMaxAttempts(); attempt++) {
453
- isLastAttempt = attempt === this.#getMaxAttempts()
454
- this.#setState({ currentAttempt: attempt })
455
-
456
- try {
457
- if (signal.aborted) {
458
- return undefined
459
- }
460
-
461
- // Check if total execution time has been exceeded
462
- const currentTotalTime = Date.now() - startTime
463
- if (
464
- this.options.maxTotalExecutionTime !== Infinity &&
465
- currentTotalTime >= this.options.maxTotalExecutionTime
466
- ) {
467
- this.options.onTotalExecutionTimeout?.(this)
468
- this.abort('total-timeout')
469
- return undefined
470
- }
471
-
472
- // Execute with individual timeout if specified
473
- if (this.options.maxExecutionTime === Infinity) {
474
- result = (await this.fn(...args)) as Awaited<ReturnType<TFn>>
475
- } else {
476
- result = (await Promise.race([
477
- this.fn(...args),
478
- new Promise<never>((_, reject) => {
479
- const timeout = setTimeout(() => {
480
- this.options.onExecutionTimeout?.(this)
481
- this.abort('execution-timeout')
482
- reject(
483
- new Error(
484
- `Execution timeout: ${this.options.maxExecutionTime}ms exceeded`,
485
- ),
486
- )
487
- }, this.options.maxExecutionTime)
488
-
489
- signal.addEventListener(
490
- 'abort',
491
- () => {
492
- clearTimeout(timeout)
493
- reject(new Error('Aborted'))
494
- },
495
- { once: true },
496
- )
497
- }),
498
- ])) as Awaited<ReturnType<TFn>>
499
- }
500
-
501
- // Check if cancelled during execution
502
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
503
- if (signal.aborted) {
504
- return undefined
505
- }
506
-
507
- const totalTime = Date.now() - startTime
508
- this.#setState({
509
- executionCount: this.store.state.executionCount + 1,
510
- isExecuting: false,
511
- lastExecutionTime: Date.now(),
512
- totalExecutionTime: totalTime,
513
- currentAttempt: 0,
514
- lastResult: result,
515
- })
516
-
517
- this.options.onSuccess?.(result as Awaited<ReturnType<TFn>>, args, this)
518
-
519
- return result
520
- } catch (error) {
521
- // Treat abort as a non-error cancellation outcome
522
- if (
523
- error &&
524
- typeof error === 'object' &&
525
- 'name' in error &&
526
- (error as Error).name === 'AbortError'
527
- ) {
528
- return undefined
529
- }
530
- lastError = error instanceof Error ? error : new Error(String(error))
531
- this.#setState({ lastError })
532
-
533
- // Call onError for every error (including during retries)
534
- this.options.onError?.(lastError, args, this)
535
-
536
- if (attempt < this.#getMaxAttempts()) {
537
- this.options.onRetry?.(attempt, lastError, this)
538
-
539
- const wait = this.#calculateWait(attempt)
540
- if (wait > 0) {
541
- // Eagerly reflect retrying status during the wait window
542
- this.#setState({ isExecuting: true, currentAttempt: attempt + 1 })
543
- await new Promise<void>((resolve) => {
544
- const timeout = setTimeout(() => {
545
- signal.removeEventListener('abort', onAbort)
546
- resolve()
547
- }, wait)
548
- const onAbort = () => {
549
- clearTimeout(timeout)
550
- signal.removeEventListener('abort', onAbort)
551
- resolve()
552
- }
553
- signal.addEventListener('abort', onAbort)
554
- })
555
- if (signal.aborted) {
556
- return undefined
557
- }
558
- }
559
- }
560
- } finally {
561
- this.options.onSettled?.(args, this)
562
- }
563
- }
564
-
565
- // Clean up total timeout
566
- if (totalTimeoutId) {
567
- clearTimeout(totalTimeoutId)
568
- }
569
-
570
- // Exhausted retries - finalize state
571
- this.#setState({ isExecuting: false })
572
- this.options.onLastError?.(lastError as Error, this)
573
- this.options.onSettled?.(args, this)
574
-
575
- if (
576
- (this.options.throwOnError === 'last' && isLastAttempt) ||
577
- this.options.throwOnError === true
578
- ) {
579
- throw lastError
580
- }
581
-
582
- return undefined
583
- }
584
-
585
- /**
586
- * Returns the current AbortSignal for the executing operation.
587
- * Use this signal in your async function to make it cancellable.
588
- * Returns null when not currently executing.
589
- *
590
- * @example
591
- * ```typescript
592
- * const retryer = new AsyncRetryer(async (userId: string) => {
593
- * const signal = retryer.getAbortSignal()
594
- * if (signal) {
595
- * return fetch(`/api/users/${userId}`, { signal })
596
- * }
597
- * return fetch(`/api/users/${userId}`)
598
- * })
599
- *
600
- * // Abort will now actually cancel the fetch
601
- * retryer.abort()
602
- * ```
603
- */
604
- getAbortSignal = (): AbortSignal | null => {
605
- return this.#abortController?.signal ?? null
606
- }
607
-
608
- /**
609
- * Cancels the current execution and any pending retries
610
- * @param reason The reason for the abort (defaults to 'manual')
611
- */
612
- abort = (
613
- reason:
614
- | 'manual'
615
- | 'execution-timeout'
616
- | 'total-timeout'
617
- | 'new-execution' = 'manual',
618
- ): void => {
619
- if (this.#abortController) {
620
- this.#abortController.abort()
621
- this.#abortController = null
622
- this.#setState({
623
- isExecuting: false,
624
- })
625
- this.options.onAbort?.(reason, this)
626
- }
627
- }
628
-
629
- /**
630
- * Resets the retryer to its initial state
631
- */
632
- reset = (): void => {
633
- this.#setState(getDefaultAsyncRetryerState<TFn>())
634
- }
635
- }
636
-
637
- /**
638
- * Creates a retry-enabled version of an async function. This is a convenience wrapper
639
- * around the AsyncRetryer class that returns the execute method.
640
- *
641
- * @param fn The async function to add retry functionality to
642
- * @param initialOptions Configuration options for the retry behavior
643
- * @returns A new function that executes the original with retry logic
644
- *
645
- * @example
646
- * ```typescript
647
- * // Define your async function normally
648
- * async function fetchData(url: string) {
649
- * const response = await fetch(url)
650
- * if (!response.ok) throw new Error('Request failed')
651
- * return response.json()
652
- * }
653
- *
654
- * // Create retry-enabled function
655
- * const fetchWithRetry = asyncRetry(fetchData, {
656
- * maxAttempts: 3,
657
- * backoff: 'exponential',
658
- * baseWait: 1000,
659
- * jitter: 0.1
660
- * })
661
- *
662
- * // Call it multiple times
663
- * const data1 = await fetchWithRetry('/api/data1')
664
- * const data2 = await fetchWithRetry('/api/data2')
665
- * ```
666
- */
667
- export function asyncRetry<TFn extends AnyAsyncFunction>(
668
- fn: TFn,
669
- initialOptions: AsyncRetryerOptions<TFn> = {},
670
- ): (...args: Parameters<TFn>) => Promise<Awaited<ReturnType<TFn>> | undefined> {
671
- const retryer = new AsyncRetryer(fn, initialOptions)
672
- return retryer.execute
673
- }