duron 0.1.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 (82) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +140 -0
  3. package/dist/action-job.d.ts +24 -0
  4. package/dist/action-job.d.ts.map +1 -0
  5. package/dist/action-job.js +108 -0
  6. package/dist/action-manager.d.ts +21 -0
  7. package/dist/action-manager.d.ts.map +1 -0
  8. package/dist/action-manager.js +78 -0
  9. package/dist/action.d.ts +129 -0
  10. package/dist/action.d.ts.map +1 -0
  11. package/dist/action.js +87 -0
  12. package/dist/adapters/adapter.d.ts +92 -0
  13. package/dist/adapters/adapter.d.ts.map +1 -0
  14. package/dist/adapters/adapter.js +424 -0
  15. package/dist/adapters/postgres/drizzle.config.d.ts +3 -0
  16. package/dist/adapters/postgres/drizzle.config.d.ts.map +1 -0
  17. package/dist/adapters/postgres/drizzle.config.js +10 -0
  18. package/dist/adapters/postgres/pglite.d.ts +13 -0
  19. package/dist/adapters/postgres/pglite.d.ts.map +1 -0
  20. package/dist/adapters/postgres/pglite.js +36 -0
  21. package/dist/adapters/postgres/postgres.d.ts +51 -0
  22. package/dist/adapters/postgres/postgres.d.ts.map +1 -0
  23. package/dist/adapters/postgres/postgres.js +867 -0
  24. package/dist/adapters/postgres/schema.d.ts +581 -0
  25. package/dist/adapters/postgres/schema.d.ts.map +1 -0
  26. package/dist/adapters/postgres/schema.default.d.ts +577 -0
  27. package/dist/adapters/postgres/schema.default.d.ts.map +1 -0
  28. package/dist/adapters/postgres/schema.default.js +3 -0
  29. package/dist/adapters/postgres/schema.js +87 -0
  30. package/dist/adapters/schemas.d.ts +516 -0
  31. package/dist/adapters/schemas.d.ts.map +1 -0
  32. package/dist/adapters/schemas.js +184 -0
  33. package/dist/client.d.ts +85 -0
  34. package/dist/client.d.ts.map +1 -0
  35. package/dist/client.js +416 -0
  36. package/dist/constants.d.ts +14 -0
  37. package/dist/constants.d.ts.map +1 -0
  38. package/dist/constants.js +22 -0
  39. package/dist/errors.d.ts +43 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +75 -0
  42. package/dist/index.d.ts +8 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +6 -0
  45. package/dist/server.d.ts +1193 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +516 -0
  48. package/dist/step-manager.d.ts +46 -0
  49. package/dist/step-manager.d.ts.map +1 -0
  50. package/dist/step-manager.js +216 -0
  51. package/dist/utils/checksum.d.ts +2 -0
  52. package/dist/utils/checksum.d.ts.map +1 -0
  53. package/dist/utils/checksum.js +6 -0
  54. package/dist/utils/p-retry.d.ts +19 -0
  55. package/dist/utils/p-retry.d.ts.map +1 -0
  56. package/dist/utils/p-retry.js +130 -0
  57. package/dist/utils/wait-for-abort.d.ts +5 -0
  58. package/dist/utils/wait-for-abort.d.ts.map +1 -0
  59. package/dist/utils/wait-for-abort.js +32 -0
  60. package/migrations/postgres/0000_lethal_speed_demon.sql +64 -0
  61. package/migrations/postgres/meta/0000_snapshot.json +606 -0
  62. package/migrations/postgres/meta/_journal.json +13 -0
  63. package/package.json +88 -0
  64. package/src/action-job.ts +201 -0
  65. package/src/action-manager.ts +166 -0
  66. package/src/action.ts +247 -0
  67. package/src/adapters/adapter.ts +969 -0
  68. package/src/adapters/postgres/drizzle.config.ts +11 -0
  69. package/src/adapters/postgres/pglite.ts +86 -0
  70. package/src/adapters/postgres/postgres.ts +1346 -0
  71. package/src/adapters/postgres/schema.default.ts +5 -0
  72. package/src/adapters/postgres/schema.ts +119 -0
  73. package/src/adapters/schemas.ts +320 -0
  74. package/src/client.ts +859 -0
  75. package/src/constants.ts +37 -0
  76. package/src/errors.ts +205 -0
  77. package/src/index.ts +14 -0
  78. package/src/server.ts +718 -0
  79. package/src/step-manager.ts +471 -0
  80. package/src/utils/checksum.ts +7 -0
  81. package/src/utils/p-retry.ts +213 -0
  82. package/src/utils/wait-for-abort.ts +40 -0
@@ -0,0 +1,471 @@
1
+ import fastq from 'fastq'
2
+ import type { Logger } from 'pino'
3
+ import type { z } from 'zod'
4
+
5
+ import {
6
+ type Action,
7
+ type ActionHandlerContext,
8
+ type StepHandlerContext,
9
+ type StepOptions,
10
+ StepOptionsSchema,
11
+ } from './action.js'
12
+ import type { Adapter, CreateOrRecoverJobStepResult } from './adapters/adapter.js'
13
+ import { STEP_STATUS_CANCELLED, STEP_STATUS_COMPLETED, STEP_STATUS_FAILED, type StepStatus } from './constants.js'
14
+ import {
15
+ ActionCancelError,
16
+ isCancelError,
17
+ isNonRetriableError,
18
+ NonRetriableError,
19
+ StepAlreadyExecutedError,
20
+ StepTimeoutError,
21
+ serializeError,
22
+ } from './errors.js'
23
+ import pRetry from './utils/p-retry.js'
24
+ import waitForAbort from './utils/wait-for-abort.js'
25
+
26
+ export interface TaskStep {
27
+ name: string
28
+ cb: (ctx: StepHandlerContext) => Promise<any>
29
+ options: StepOptions
30
+ abortSignal: AbortSignal
31
+ }
32
+
33
+ /**
34
+ * StepStore manages step records in the database.
35
+ * Provides methods to create, update, and delay steps.
36
+ */
37
+ export class StepStore {
38
+ #adapter: Adapter
39
+
40
+ // ============================================================================
41
+ // Constructor
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Create a new StepStore instance.
46
+ *
47
+ * @param adapter - The database adapter to use for step operations
48
+ */
49
+ constructor(adapter: Adapter) {
50
+ this.#adapter = adapter
51
+ }
52
+
53
+ // ============================================================================
54
+ // Public API Methods
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Get or create a step record in the database.
59
+ *
60
+ * @param jobId - The ID of the job this step belongs to
61
+ * @param name - The name of the step
62
+ * @param timeoutMs - Timeout in milliseconds for the step
63
+ * @param retriesLimit - Maximum number of retries for the step
64
+ * @returns Promise resolving to the created step ID
65
+ * @throws Error if step creation fails
66
+ */
67
+ async getOrCreate(jobId: string, name: string, timeoutMs: number, retriesLimit: number) {
68
+ try {
69
+ return await this.#adapter.createOrRecoverJobStep({
70
+ jobId,
71
+ name,
72
+ timeoutMs,
73
+ retriesLimit,
74
+ })
75
+ } catch (error) {
76
+ throw new NonRetriableError(`Failed to get or create step "${name}" for job "${jobId}"`, { cause: error })
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Update the status of a step in the database.
82
+ *
83
+ * @param stepId - The ID of the step to update
84
+ * @param status - The new status (completed, failed, or cancelled)
85
+ * @param output - Optional output data for completed steps
86
+ * @param error - Optional error data for failed steps
87
+ * @returns Promise resolving to `true` if update succeeded, `false` otherwise
88
+ */
89
+ async updateStatus(stepId: string, status: StepStatus, output?: any, error?: any): Promise<boolean> {
90
+ if (status === STEP_STATUS_COMPLETED) {
91
+ return this.#adapter.completeJobStep({ stepId, output })
92
+ } else if (status === STEP_STATUS_FAILED) {
93
+ return this.#adapter.failJobStep({ stepId, error })
94
+ } else if (status === STEP_STATUS_CANCELLED) {
95
+ return this.#adapter.cancelJobStep({ stepId })
96
+ }
97
+ return false
98
+ }
99
+
100
+ /**
101
+ * Delay a step execution.
102
+ * Used when a step fails and needs to be retried after a delay.
103
+ *
104
+ * @param stepId - The ID of the step to delay
105
+ * @param delayMs - The delay in milliseconds before retrying
106
+ * @param error - The error that caused the delay
107
+ * @returns Promise resolving to `true` if delayed successfully, `false` otherwise
108
+ */
109
+ async delay(stepId: string, delayMs: number, error: any): Promise<boolean> {
110
+ return this.#adapter.delayJobStep({ stepId, delayMs, error })
111
+ }
112
+ }
113
+
114
+ export interface StepManagerOptions {
115
+ jobId: string
116
+ actionName: string
117
+ adapter: Adapter
118
+ logger: Logger
119
+ concurrencyLimit: number
120
+ }
121
+
122
+ /**
123
+ * StepManager manages steps for a single ActionJob.
124
+ * Each ActionJob has its own StepManager instance.
125
+ */
126
+ export class StepManager {
127
+ #jobId: string
128
+ #actionName: string
129
+ #stepStore: StepStore
130
+ #queue: fastq.queueAsPromised<TaskStep, any>
131
+ #logger: Logger
132
+ // each step name should be executed only once per action job
133
+ #historySteps = new Set<string>()
134
+
135
+ // ============================================================================
136
+ // Constructor
137
+ // ============================================================================
138
+
139
+ /**
140
+ * Create a new StepManager instance.
141
+ *
142
+ * @param options - Configuration options for the step manager
143
+ */
144
+ constructor(options: StepManagerOptions) {
145
+ this.#jobId = options.jobId
146
+ this.#actionName = options.actionName
147
+ this.#logger = options.logger
148
+ this.#stepStore = new StepStore(options.adapter)
149
+ this.#queue = fastq.promise(async (task: TaskStep) => {
150
+ if (this.#historySteps.has(task.name)) {
151
+ throw new StepAlreadyExecutedError(task.name, this.#jobId, this.#actionName)
152
+ }
153
+ this.#historySteps.add(task.name)
154
+ return this.#executeStep(task.name, task.cb, task.options, task.abortSignal)
155
+ }, options.concurrencyLimit)
156
+ }
157
+
158
+ // ============================================================================
159
+ // Public API Methods
160
+ // ============================================================================
161
+
162
+ /**
163
+ * Create an ActionContext for the action handler.
164
+ * The context provides access to input, variables, logger, and the step function.
165
+ *
166
+ * @param job - The job data including ID, input, and optional group key
167
+ * @param action - The action definition
168
+ * @param variables - Variables available to the action
169
+ * @param abortSignal - Abort signal for cancelling the action
170
+ * @param logger - Pino child logger for this job
171
+ * @returns ActionHandlerContext instance
172
+ */
173
+ createActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVariables = Record<string, unknown>>(
174
+ job: { id: string; input: z.infer<TInput>; groupKey?: string },
175
+ action: Action<TInput, TOutput, TVariables>,
176
+ variables: TVariables,
177
+ abortSignal: AbortSignal,
178
+ logger: Logger,
179
+ ): ActionHandlerContext<TInput, TVariables> {
180
+ return new ActionContext(this, job, action, variables, abortSignal, logger)
181
+ }
182
+
183
+ /**
184
+ * Queue a step task for execution.
185
+ *
186
+ * @param task - The step task to queue
187
+ * @returns Promise resolving to the step result
188
+ */
189
+ async push(task: TaskStep): Promise<any> {
190
+ return this.#queue.push(task)
191
+ }
192
+
193
+ /**
194
+ * Clean up step queues by waiting for them to drain.
195
+ * Should be called when the job completes or is cancelled.
196
+ */
197
+ async drain(): Promise<void> {
198
+ await this.#queue.drain()
199
+ }
200
+
201
+ /**
202
+ * Execute a step with retry logic and timeout handling.
203
+ * Creates a step record, queues the execution, and handles errors appropriately.
204
+ *
205
+ * @param name - The name of the step
206
+ * @param cb - The step handler function
207
+ * @param options - Step options including concurrency, retry, and expire settings
208
+ * @param abortSignal - Abort signal for cancelling the step
209
+ * @returns Promise resolving to the step result
210
+ * @throws StepTimeoutError if the step times out
211
+ * @throws StepCancelError if the step is cancelled
212
+ * @throws Error if the step fails
213
+ */
214
+ async #executeStep<TResult>(
215
+ name: string,
216
+ cb: (ctx: StepHandlerContext) => Promise<TResult>,
217
+ options: StepOptions,
218
+ abortSignal: AbortSignal,
219
+ ): Promise<TResult> {
220
+ const expire = options.expire
221
+ const retryOptions = options.retry
222
+ let step: CreateOrRecoverJobStepResult | null = null
223
+
224
+ const executeStep = async (): Promise<TResult> => {
225
+ if (!step) {
226
+ if (abortSignal.aborted) {
227
+ throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled before create step' })
228
+ }
229
+
230
+ // Create step record
231
+ const newStep = await this.#stepStore.getOrCreate(this.#jobId, name, expire, retryOptions.limit)
232
+ if (!newStep) {
233
+ throw new NonRetriableError(
234
+ `Failed to create step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
235
+ { cause: 'step not created' },
236
+ )
237
+ }
238
+
239
+ step = newStep
240
+
241
+ if (abortSignal.aborted) {
242
+ throw new ActionCancelError(this.#actionName, this.#jobId, { cause: 'step cancelled after create step' })
243
+ }
244
+
245
+ if (step.status === STEP_STATUS_COMPLETED) {
246
+ // this is how we recover a completed step
247
+ this.#logger.debug(
248
+ { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
249
+ '[StepManager] Step recovered (already completed)',
250
+ )
251
+ return step.output as TResult
252
+ } else if (step.status === STEP_STATUS_FAILED) {
253
+ throw new NonRetriableError(
254
+ `Cannot recover a failed step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
255
+ {
256
+ cause: step.error,
257
+ },
258
+ )
259
+ } else if (step.status === STEP_STATUS_CANCELLED) {
260
+ throw new NonRetriableError(
261
+ `Cannot recover a cancelled step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`,
262
+ { cause: step.error },
263
+ )
264
+ }
265
+
266
+ // Log step start
267
+ this.#logger.debug(
268
+ { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
269
+ '[StepManager] Step started executing',
270
+ )
271
+ }
272
+
273
+ const stepAbortController = new AbortController()
274
+ const timeoutId = setTimeout(() => {
275
+ const timeoutError = new StepTimeoutError(name, this.#jobId, expire)
276
+ stepAbortController.abort(timeoutError)
277
+ }, expire)
278
+
279
+ timeoutId?.unref?.()
280
+
281
+ // Combine abort signals
282
+ const signal = AbortSignal.any([abortSignal, stepAbortController.signal])
283
+
284
+ try {
285
+ // Race between abort signal and callback execution
286
+ const abortPromise = waitForAbort(signal)
287
+ const callbackPromise = cb({ signal })
288
+
289
+ let result: any = null
290
+
291
+ await Promise.race([
292
+ abortPromise.promise,
293
+ callbackPromise
294
+ .then((res) => {
295
+ if (res !== undefined && res !== null) {
296
+ result = res
297
+ }
298
+ })
299
+ .finally(() => {
300
+ abortPromise.release()
301
+ }),
302
+ ])
303
+
304
+ // Update step as completed
305
+ const completed = await this.#stepStore.updateStatus(step.id, 'completed', result)
306
+ if (!completed) {
307
+ throw new Error(`Failed to complete step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`)
308
+ }
309
+
310
+ // Log step completion
311
+ this.#logger.debug(
312
+ { jobId: this.#jobId, actionName: this.#actionName, stepName: name, stepId: step.id },
313
+ '[StepManager] Step finished executing',
314
+ )
315
+
316
+ return result as TResult
317
+ } finally {
318
+ clearTimeout(timeoutId)
319
+ }
320
+ }
321
+
322
+ // Apply retry logic - skip retries for NonRetriableError
323
+ return pRetry(executeStep, {
324
+ retries: retryOptions.limit,
325
+ factor: retryOptions.factor,
326
+ randomize: false,
327
+ signal: abortSignal,
328
+ minTimeout: retryOptions.minTimeout,
329
+ maxTimeout: retryOptions.maxTimeout,
330
+ onFailedAttempt: async (ctx) => {
331
+ const error = ctx.error as any
332
+ // Don't retry if error is non-retriable
333
+ if (
334
+ isNonRetriableError(error) ||
335
+ (error.cause && isNonRetriableError(error.cause)) ||
336
+ (error instanceof Error && error.name === 'AbortError' && isNonRetriableError(error.cause))
337
+ ) {
338
+ throw error
339
+ }
340
+
341
+ if (ctx.retriesLeft > 0 && step) {
342
+ const delayed = await this.#stepStore.delay(step.id, ctx.finalDelay, serializeError(error))
343
+ if (!delayed) {
344
+ throw new Error(`Failed to delay step "${name}" for job "${this.#jobId}" action "${this.#actionName}"`)
345
+ }
346
+ }
347
+ },
348
+ }).catch(async (error) => {
349
+ if (step) {
350
+ if (isCancelError(error)) {
351
+ await this.#stepStore.updateStatus(step.id, 'cancelled')
352
+ } else {
353
+ await this.#stepStore.updateStatus(step.id, STEP_STATUS_FAILED, null, serializeError(error))
354
+ }
355
+ }
356
+ throw error
357
+ })
358
+ }
359
+ }
360
+
361
+ // ============================================================================
362
+ // ActionContext Class
363
+ // ============================================================================
364
+
365
+ /**
366
+ * ActionContext provides the context for action handlers.
367
+ * It implements ActionHandlerContext and provides access to input, variables, logger, and the step function.
368
+ */
369
+ class ActionContext<TInput extends z.ZodObject, TOutput extends z.ZodObject, TVariables = Record<string, unknown>>
370
+ implements ActionHandlerContext<TInput, TVariables>
371
+ {
372
+ #stepManager: StepManager
373
+ #variables: TVariables
374
+ #abortSignal: AbortSignal
375
+ #logger: Logger
376
+ #input: z.infer<TInput>
377
+ #jobId: string
378
+ #groupKey: string = '@default'
379
+ #action: Action<TInput, TOutput, TVariables>
380
+
381
+ // ============================================================================
382
+ // Constructor
383
+ // ============================================================================
384
+
385
+ constructor(
386
+ stepManager: StepManager,
387
+ job: { id: string; input: z.infer<TInput>; groupKey?: string },
388
+ action: Action<TInput, TOutput, TVariables>,
389
+ variables: TVariables,
390
+ abortSignal: AbortSignal,
391
+ logger: Logger,
392
+ ) {
393
+ this.#stepManager = stepManager
394
+ this.#variables = variables
395
+ this.#abortSignal = abortSignal
396
+ this.#logger = logger
397
+ this.#action = action
398
+ this.#jobId = job.id
399
+ this.#groupKey = job.groupKey ?? '@default'
400
+ if (action.input) {
401
+ this.#input = action.input.parse(job.input, {
402
+ error: () => 'Error parsing action input',
403
+ reportInput: true,
404
+ })
405
+ }
406
+ this.#input = job.input ?? {}
407
+ }
408
+
409
+ // ============================================================================
410
+ // Public API Methods
411
+ // ============================================================================
412
+
413
+ /**
414
+ * Get the input data for this action.
415
+ */
416
+ get input(): z.infer<TInput> {
417
+ return this.#input
418
+ }
419
+
420
+ /**
421
+ * Get the job ID for this action context.
422
+ *
423
+ * @returns The job ID
424
+ */
425
+ get jobId(): string {
426
+ return this.#jobId
427
+ }
428
+
429
+ /**
430
+ * Get the group key for this action context.
431
+ *
432
+ * @returns The group key
433
+ */
434
+ get groupKey(): string {
435
+ return this.#groupKey
436
+ }
437
+
438
+ /**
439
+ * Get the variables available to this action.
440
+ */
441
+ get var(): TVariables {
442
+ return this.#variables
443
+ }
444
+
445
+ /**
446
+ * Get the logger for this action job.
447
+ */
448
+ get logger(): Logger {
449
+ return this.#logger
450
+ }
451
+
452
+ /**
453
+ * Execute a step within the action.
454
+ *
455
+ * @param name - The name of the step
456
+ * @param cb - The step handler function
457
+ * @param options - Optional step options (will be merged with defaults)
458
+ * @returns Promise resolving to the step result
459
+ */
460
+ async step<TResult>(
461
+ name: string,
462
+ cb: (ctx: StepHandlerContext) => Promise<TResult>,
463
+ options: z.input<typeof StepOptionsSchema> = {},
464
+ ): Promise<TResult> {
465
+ const parsedOptions = StepOptionsSchema.parse({
466
+ ...this.#action.steps,
467
+ ...options,
468
+ })
469
+ return this.#stepManager.push({ name, cb, options: parsedOptions, abortSignal: this.#abortSignal })
470
+ }
471
+ }
@@ -0,0 +1,7 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ export default function generateChecksum(code: string) {
4
+ const hash = createHash('md5')
5
+ hash.update(code)
6
+ return hash.digest('hex')
7
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @author github.com/sindresorhus
3
+ * @license MIT
4
+ * @description A refactor version of p-retry remove unnecessary code and add the necessary context to the retry function.
5
+ */
6
+
7
+ interface RetryOptions {
8
+ retries: number
9
+ factor: number
10
+ minTimeout: number
11
+ maxTimeout: number
12
+ maxRetryTime?: number
13
+ randomize: boolean
14
+ signal: AbortSignal
15
+ onFailedAttempt: (context: {
16
+ error: Error
17
+ attemptNumber: number
18
+ retriesLeft: number
19
+ retriesConsumed: number
20
+ finalDelay: number
21
+ }) => Promise<void>
22
+ }
23
+
24
+ function validateRetries(retries: number) {
25
+ if (typeof retries === 'number') {
26
+ if (retries < 0) {
27
+ throw new TypeError('Expected `retries` to be a non-negative number.')
28
+ }
29
+
30
+ if (Number.isNaN(retries)) {
31
+ throw new TypeError('Expected `retries` to be a valid number or Infinity, got NaN.')
32
+ }
33
+ } else if (retries !== undefined) {
34
+ throw new TypeError('Expected `retries` to be a number or Infinity.')
35
+ }
36
+ }
37
+
38
+ function validateNumberOption(name: string, value: number, { min = 0, allowInfinity = false } = {}) {
39
+ if (value === undefined) {
40
+ return
41
+ }
42
+
43
+ if (typeof value !== 'number' || Number.isNaN(value)) {
44
+ throw new TypeError(`Expected \`${name}\` to be a number${allowInfinity ? ' or Infinity' : ''}.`)
45
+ }
46
+
47
+ if (!allowInfinity && !Number.isFinite(value)) {
48
+ throw new TypeError(`Expected \`${name}\` to be a finite number.`)
49
+ }
50
+
51
+ if (value < min) {
52
+ throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`)
53
+ }
54
+ }
55
+
56
+ function calculateDelay(retriesConsumed: number, options: RetryOptions) {
57
+ const attempt = Math.max(1, retriesConsumed + 1)
58
+ const random = options.randomize ? Math.random() + 1 : 1
59
+
60
+ let timeout = Math.round(random * options.minTimeout * options.factor ** (attempt - 1))
61
+ timeout = Math.min(timeout, options.maxTimeout)
62
+
63
+ return timeout
64
+ }
65
+
66
+ function calculateRemainingTime(start: number, max: number) {
67
+ if (!Number.isFinite(max)) {
68
+ return max
69
+ }
70
+
71
+ return max - (performance.now() - start)
72
+ }
73
+
74
+ async function onAttemptFailure({
75
+ error,
76
+ attemptNumber,
77
+ retriesConsumed,
78
+ startTime,
79
+ options,
80
+ }: {
81
+ error: Error
82
+ attemptNumber: number
83
+ retriesConsumed: number
84
+ startTime: number
85
+ options: RetryOptions
86
+ }) {
87
+ const normalizedError =
88
+ error instanceof Error ? error : new TypeError(`Non-error was thrown: "${error}". You should only throw errors.`)
89
+
90
+ const retriesLeft = Number.isFinite(options.retries)
91
+ ? Math.max(0, options.retries - retriesConsumed)
92
+ : options.retries
93
+
94
+ const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY
95
+
96
+ const delayTime = calculateDelay(retriesConsumed, options)
97
+ const remainingTime = calculateRemainingTime(startTime, maxRetryTime)
98
+ const finalDelay = Math.min(delayTime, remainingTime)
99
+
100
+ if (remainingTime <= 0) {
101
+ throw normalizedError
102
+ }
103
+
104
+ const context = Object.freeze({
105
+ error: normalizedError,
106
+ attemptNumber,
107
+ retriesLeft,
108
+ retriesConsumed,
109
+ finalDelay,
110
+ })
111
+
112
+ await options.onFailedAttempt(context)
113
+
114
+ if (remainingTime <= 0 || retriesLeft <= 0) {
115
+ throw normalizedError
116
+ }
117
+
118
+ if (normalizedError instanceof TypeError) {
119
+ options.signal?.throwIfAborted()
120
+ return false
121
+ }
122
+
123
+ if (finalDelay > 0) {
124
+ await new Promise((resolve, reject) => {
125
+ const onAbort = () => {
126
+ clearTimeout(timeoutToken)
127
+ options.signal?.removeEventListener('abort', onAbort)
128
+ reject(options.signal.reason)
129
+ }
130
+
131
+ const timeoutToken = setTimeout(() => {
132
+ options.signal?.removeEventListener('abort', onAbort)
133
+ resolve(undefined)
134
+ }, finalDelay)
135
+
136
+ timeoutToken.unref?.()
137
+
138
+ options.signal?.addEventListener('abort', onAbort, { once: true })
139
+ })
140
+ }
141
+
142
+ options.signal?.throwIfAborted()
143
+
144
+ return true
145
+ }
146
+
147
+ export default async function pRetry<TResult>(
148
+ input: (attemptNumber: number) => Promise<TResult>,
149
+ options: RetryOptions,
150
+ ): Promise<TResult> {
151
+ options = { ...options }
152
+
153
+ validateRetries(options.retries)
154
+
155
+ if (Object.hasOwn(options, 'forever')) {
156
+ throw new Error(
157
+ 'The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.',
158
+ )
159
+ }
160
+
161
+ options.retries ??= 10
162
+ options.factor ??= 2
163
+ options.minTimeout ??= 1000
164
+ options.maxTimeout ??= Number.POSITIVE_INFINITY
165
+ options.maxRetryTime ??= Number.POSITIVE_INFINITY
166
+ options.randomize ??= false
167
+
168
+ // Validate numeric options and normalize edge cases
169
+ validateNumberOption('factor', options.factor as number, { min: 0, allowInfinity: false })
170
+ validateNumberOption('minTimeout', options.minTimeout as number, { min: 0, allowInfinity: false })
171
+ validateNumberOption('maxTimeout', options.maxTimeout as number, { min: 0, allowInfinity: true })
172
+ validateNumberOption('maxRetryTime', options.maxRetryTime as number, { min: 0, allowInfinity: true })
173
+
174
+ // Treat non-positive factor as 1 to avoid zero backoff or negative behavior
175
+ if (!(options.factor > 0)) {
176
+ options.factor = 1
177
+ }
178
+
179
+ options.signal?.throwIfAborted()
180
+
181
+ let attemptNumber = 0
182
+ let retriesConsumed = 0
183
+ const startTime = performance.now()
184
+
185
+ while (Number.isFinite(options.retries) ? retriesConsumed <= options.retries : true) {
186
+ attemptNumber++
187
+
188
+ try {
189
+ options.signal?.throwIfAborted()
190
+
191
+ const result = await input(attemptNumber)
192
+
193
+ options.signal?.throwIfAborted()
194
+
195
+ return result
196
+ } catch (error) {
197
+ if (
198
+ await onAttemptFailure({
199
+ error: error as Error,
200
+ attemptNumber,
201
+ retriesConsumed,
202
+ startTime,
203
+ options,
204
+ })
205
+ ) {
206
+ retriesConsumed++
207
+ }
208
+ }
209
+ }
210
+
211
+ // Should not reach here, but in case it does, throw an error
212
+ throw new Error('Retry attempts exhausted without throwing an error.')
213
+ }