Skip to content

Commit 2077558

Browse files
committed
test: add integration tests for retryOnTransientError error recovery
Signed-off-by: leocavalcante <leo@cavalcante.dev>
1 parent 1ed56db commit 2077558

1 file changed

Lines changed: 160 additions & 0 deletions

File tree

tests/paths.test.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,166 @@ describe("paths.mjs exports", () => {
641641
expect(elapsed).toBeLessThan(150)
642642
})
643643

644+
describe("error recovery integration", () => {
645+
it("should return immediately on first attempt success without any retries", async () => {
646+
const attemptTimestamps: number[] = []
647+
const start = Date.now()
648+
649+
const result = await retryOnTransientError(
650+
() => {
651+
attemptTimestamps.push(Date.now() - start)
652+
return { data: "immediate success", timestamp: Date.now() }
653+
},
654+
{ retries: 5, initialDelayMs: 100 },
655+
)
656+
657+
// Verify single attempt
658+
expect(attemptTimestamps).toHaveLength(1)
659+
// Verify result is returned correctly
660+
expect(result.data).toBe("immediate success")
661+
// Verify no delay was incurred (should be nearly instant)
662+
expect(attemptTimestamps[0]).toBeLessThan(50)
663+
})
664+
665+
it("should respect exact retry count: retries=1 means 2 total attempts", async () => {
666+
const attempts: number[] = []
667+
668+
await expect(
669+
retryOnTransientError(
670+
() => {
671+
attempts.push(attempts.length + 1)
672+
throw Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
673+
},
674+
{ retries: 1, initialDelayMs: 1 },
675+
),
676+
).rejects.toThrow("EAGAIN")
677+
678+
// retries=1 means: 1 initial attempt + 1 retry = 2 total
679+
expect(attempts).toEqual([1, 2])
680+
})
681+
682+
it("should respect exact retry count: retries=4 means 5 total attempts", async () => {
683+
const attempts: number[] = []
684+
685+
await expect(
686+
retryOnTransientError(
687+
() => {
688+
attempts.push(attempts.length + 1)
689+
throw Object.assign(new Error("EBUSY"), { code: "EBUSY" })
690+
},
691+
{ retries: 4, initialDelayMs: 1 },
692+
),
693+
).rejects.toThrow("EBUSY")
694+
695+
// retries=4 means: 1 initial attempt + 4 retries = 5 total
696+
expect(attempts).toEqual([1, 2, 3, 4, 5])
697+
})
698+
699+
it("should throw non-transient error immediately without any retry attempts", async () => {
700+
const errorCodes = ["ENOENT", "EACCES", "EPERM", "ENOSPC", "EROFS"]
701+
702+
for (const code of errorCodes) {
703+
const attempts: number[] = []
704+
705+
await expect(
706+
retryOnTransientError(
707+
() => {
708+
attempts.push(attempts.length + 1)
709+
throw Object.assign(new Error(code), { code })
710+
},
711+
{ retries: 10, initialDelayMs: 1 },
712+
),
713+
).rejects.toThrow(code)
714+
715+
// Non-transient errors should fail immediately with only 1 attempt
716+
expect(attempts).toEqual([1])
717+
}
718+
})
719+
720+
it("should return the exact result value after successful retry", async () => {
721+
let attemptCount = 0
722+
const expectedResult = {
723+
nested: { value: 42, array: [1, 2, 3] },
724+
status: "recovered",
725+
}
726+
727+
const result = await retryOnTransientError(
728+
() => {
729+
attemptCount++
730+
if (attemptCount < 3) {
731+
throw Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
732+
}
733+
return expectedResult
734+
},
735+
{ retries: 5, initialDelayMs: 1 },
736+
)
737+
738+
// Verify the exact object is returned
739+
expect(result).toEqual(expectedResult)
740+
expect(result.nested.value).toBe(42)
741+
expect(result.nested.array).toEqual([1, 2, 3])
742+
expect(result.status).toBe("recovered")
743+
// Verify it took exactly 3 attempts
744+
expect(attemptCount).toBe(3)
745+
})
746+
747+
it("should preserve error details when throwing after exhausting retries", async () => {
748+
const customError = Object.assign(new Error("Resource busy: /tmp/file.lock"), {
749+
code: "EBUSY",
750+
path: "/tmp/file.lock",
751+
syscall: "open",
752+
})
753+
754+
try {
755+
await retryOnTransientError(
756+
() => {
757+
throw customError
758+
},
759+
{ retries: 2, initialDelayMs: 1 },
760+
)
761+
expect.unreachable("Should have thrown")
762+
} catch (err) {
763+
// Verify the exact same error object is thrown
764+
expect(err).toBe(customError)
765+
expect((err as NodeJS.ErrnoException).code).toBe("EBUSY")
766+
expect((err as NodeJS.ErrnoException).path).toBe("/tmp/file.lock")
767+
expect((err as NodeJS.ErrnoException).syscall).toBe("open")
768+
}
769+
})
770+
771+
it("should track state correctly across retry attempts", async () => {
772+
const stateLog: Array<{ attempt: number; timestamp: number }> = []
773+
const start = Date.now()
774+
775+
const result = await retryOnTransientError(
776+
() => {
777+
const attempt = stateLog.length + 1
778+
stateLog.push({ attempt, timestamp: Date.now() - start })
779+
780+
if (attempt < 4) {
781+
throw Object.assign(new Error("EAGAIN"), { code: "EAGAIN" })
782+
}
783+
return `success on attempt ${attempt}`
784+
},
785+
{ retries: 5, initialDelayMs: 10 },
786+
)
787+
788+
// Verify correct number of attempts
789+
expect(stateLog).toHaveLength(4)
790+
expect(stateLog.map((s) => s.attempt)).toEqual([1, 2, 3, 4])
791+
792+
// Verify result reflects final successful attempt
793+
expect(result).toBe("success on attempt 4")
794+
795+
// Verify delays occurred between attempts (exponential backoff)
796+
// Attempt 1: ~0ms, Attempt 2: ~10ms, Attempt 3: ~30ms, Attempt 4: ~70ms
797+
expect(stateLog[0]?.timestamp).toBeLessThan(10)
798+
expect(stateLog[1]?.timestamp).toBeGreaterThanOrEqual(5)
799+
expect(stateLog[2]?.timestamp).toBeGreaterThanOrEqual(20)
800+
expect(stateLog[3]?.timestamp).toBeGreaterThanOrEqual(50)
801+
})
802+
})
803+
644804
describe("input validation", () => {
645805
it("should throw TypeError when fn is null", async () => {
646806
await expect(retryOnTransientError(null as unknown as () => void)).rejects.toThrow(

0 commit comments

Comments
 (0)