@tanstack/pacer 0.15.4 → 0.16.1

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