Skip to content

Commit d2e6fdb

Browse files
committed
feat: implement exponential backoff in retryOnTransientError
Signed-off-by: leocavalcante <[email protected]>
1 parent a7fe355 commit d2e6fdb

File tree

3 files changed

+65
-23
lines changed

3 files changed

+65
-23
lines changed

src/paths.d.mts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -79,31 +79,32 @@ export function isTransientError(error: Error & { code?: string }): boolean
7979
export interface RetryOptions {
8080
/** Number of retry attempts (default: 3) */
8181
retries?: number
82-
/** Delay between retries in milliseconds (default: 100) */
83-
delayMs?: number
82+
/** Initial delay in milliseconds, doubles on each retry (default: 100) */
83+
initialDelayMs?: number
8484
}
8585

8686
/**
87-
* Retries a function on transient filesystem errors.
87+
* Retries a function on transient filesystem errors with exponential backoff.
8888
*
8989
* If the function throws a transient error (EAGAIN, EBUSY), it will be retried
90-
* up to the specified number of times with a delay between attempts.
90+
* up to the specified number of times with exponentially increasing delays
91+
* between attempts (e.g., 100ms, 200ms, 400ms).
9192
*
9293
* @template T - The return type of the function
9394
* @param fn - The function to execute
94-
* @param options - Retry options (retries, delayMs)
95+
* @param options - Retry options (retries, initialDelayMs)
9596
* @returns The result of the function
9697
* @throws The last error if all retries fail
9798
*
9899
* @example
99-
* // Retry a file copy operation
100+
* // Retry a file copy operation (delays: 100ms, 200ms, 400ms)
100101
* await retryOnTransientError(() => copyFileSync(src, dest))
101102
*
102103
* @example
103-
* // Custom retry options
104+
* // Custom retry options (delays: 50ms, 100ms, 200ms, 400ms, 800ms)
104105
* await retryOnTransientError(
105106
* () => unlinkSync(path),
106-
* { retries: 5, delayMs: 200 }
107+
* { retries: 5, initialDelayMs: 50 }
107108
* )
108109
*/
109110
export function retryOnTransientError<T>(

src/paths.mjs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,30 +121,31 @@ function delay(ms) {
121121
}
122122

123123
/**
124-
* Retries a function on transient filesystem errors.
124+
* Retries a function on transient filesystem errors with exponential backoff.
125125
*
126126
* If the function throws a transient error (EAGAIN, EBUSY), it will be retried
127-
* up to the specified number of times with a delay between attempts.
127+
* up to the specified number of times with exponentially increasing delays
128+
* between attempts (e.g., 100ms, 200ms, 400ms).
128129
*
129130
* @template T
130131
* @param {() => T | Promise<T>} fn - The function to execute
131-
* @param {{ retries?: number, delayMs?: number }} [options] - Retry options
132+
* @param {{ retries?: number, initialDelayMs?: number }} [options] - Retry options
132133
* @returns {Promise<T>} The result of the function
133134
* @throws {Error} The last error if all retries fail
134135
*
135136
* @example
136-
* // Retry a file copy operation
137+
* // Retry a file copy operation (delays: 100ms, 200ms, 400ms)
137138
* await retryOnTransientError(() => copyFileSync(src, dest))
138139
*
139140
* @example
140-
* // Custom retry options
141+
* // Custom retry options (delays: 50ms, 100ms, 200ms, 400ms, 800ms)
141142
* await retryOnTransientError(
142143
* () => unlinkSync(path),
143-
* { retries: 5, delayMs: 200 }
144+
* { retries: 5, initialDelayMs: 50 }
144145
* )
145146
*/
146147
export async function retryOnTransientError(fn, options = {}) {
147-
const { retries = 3, delayMs = 100 } = options
148+
const { retries = 3, initialDelayMs = 100 } = options
148149
let lastError
149150

150151
for (let attempt = 0; attempt <= retries; attempt++) {
@@ -159,8 +160,9 @@ export async function retryOnTransientError(fn, options = {}) {
159160
throw err
160161
}
161162

162-
// Wait before retrying
163-
await delay(delayMs)
163+
// Calculate exponential backoff delay: initialDelayMs * 2^attempt
164+
const backoffDelay = initialDelayMs * 2 ** attempt
165+
await delay(backoffDelay)
164166
}
165167
}
166168

tests/paths.test.ts

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,26 +355,65 @@ describe("paths.mjs exports", () => {
355355
expect(callCount).toBe(2)
356356
})
357357

358-
it("should use default delay of 100ms", async () => {
358+
it("should use exponential backoff with default initial delay of 100ms", async () => {
359359
let callCount = 0
360+
const timestamps: number[] = []
360361
const start = Date.now()
361362
await retryOnTransientError(
362363
() => {
364+
timestamps.push(Date.now() - start)
363365
callCount++
364-
if (callCount < 3) {
366+
if (callCount < 4) {
365367
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
366368
throw err
367369
}
368370
return "success"
369371
},
370372
{ retries: 3 },
371373
)
374+
// 3 retries with exponential backoff: 100ms, 200ms, 400ms = 700ms total
372375
const elapsed = Date.now() - start
373-
// Should have at least 2 delays of ~100ms each
374-
expect(elapsed).toBeGreaterThanOrEqual(150)
376+
expect(elapsed).toBeGreaterThanOrEqual(600) // Allow some timing variance
377+
expect(elapsed).toBeLessThan(1000) // Should not be too long
375378
})
376379

377-
it("should respect custom delay option", async () => {
380+
it("should double delay on each retry (exponential backoff)", async () => {
381+
const delays: number[] = []
382+
let lastTimestamp = Date.now()
383+
let callCount = 0
384+
385+
await retryOnTransientError(
386+
() => {
387+
const now = Date.now()
388+
if (callCount > 0) {
389+
delays.push(now - lastTimestamp)
390+
}
391+
lastTimestamp = now
392+
callCount++
393+
if (callCount < 4) {
394+
const err = Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
395+
throw err
396+
}
397+
return "success"
398+
},
399+
{ retries: 3, initialDelayMs: 50 },
400+
)
401+
402+
// With initialDelayMs=50, delays should be approximately: 50, 100, 200
403+
expect(delays).toHaveLength(3)
404+
// Verify each delay is approximately double the previous (with tolerance)
405+
// First delay should be ~50ms
406+
expect(delays[0]).toBeGreaterThanOrEqual(40)
407+
expect(delays[0]).toBeLessThan(100)
408+
// Second delay should be ~100ms (2x first)
409+
expect(delays[1]).toBeGreaterThanOrEqual(80)
410+
expect(delays[1]).toBeLessThan(180)
411+
// Third delay should be ~200ms (2x second)
412+
expect(delays[2]).toBeGreaterThanOrEqual(160)
413+
expect(delays[2]).toBeLessThan(320)
414+
})
415+
416+
it("should respect custom initialDelayMs option", async () => {
378417
let callCount = 0
379418
const start = Date.now()
380419
await retryOnTransientError(
@@ -386,7 +425,7 @@ describe("paths.mjs exports", () => {
386425
}
387426
return "success"
388427
},
389-
{ delayMs: 50 },
428+
{ initialDelayMs: 50 },
390429
)
391430
const elapsed = Date.now() - start
392431
// Should have 1 delay of ~50ms

0 commit comments

Comments
 (0)