diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 3b456fd10..d660e3224 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed `fetchWithRetry()` aborts during retry backoff to preserve the documented `"Request was aborted"` error contract ([#8450](https://github.com/can1357/oh-my-pi/issues/8450)). + ## [17.3.0] - 2026-08-13 ### Fixed diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts index 51263af31..dce664130 100644 --- a/packages/utils/src/fetch-retry.ts +++ b/packages/utils/src/fetch-retry.ts @@ -220,7 +220,7 @@ export async function fetchWithRetry( if (signal?.aborted) throw new Error("Request was aborted"); const wrapped = wrapNetworkError(error); if (attempt + 1 >= maxAttempts) throw wrapped; - await scheduler.wait(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), { signal }); + await waitForRetry(resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), signal); continue; } @@ -234,7 +234,7 @@ export async function fetchWithRetry( if (hint !== undefined && hint > maxDelayMs) return response; const delayMs = Math.min(hint ?? resolveDefaultDelay(defaultDelayMs, attempt, maxDelayMs), maxDelayMs); - await scheduler.wait(delayMs, { signal }); + await waitForRetry(delayMs, signal); } } @@ -251,6 +251,15 @@ function mergeInit(base: RequestInit, overlay: RequestInit, timeout: number | fa return merged; } +async function waitForRetry(delayMs: number, signal: AbortSignal | undefined): Promise { + try { + await scheduler.wait(delayMs, { signal }); + } catch (error) { + if (signal?.aborted) throw new Error("Request was aborted"); + throw error; + } +} + function wrapNetworkError(error: unknown): Error { if (error instanceof Error) { if (error.name === "AbortError" || error.message === "Request was aborted") { diff --git a/packages/utils/test/fetch-retry.test.ts b/packages/utils/test/fetch-retry.test.ts index 5c8be8e06..3f83fd95b 100644 --- a/packages/utils/test/fetch-retry.test.ts +++ b/packages/utils/test/fetch-retry.test.ts @@ -78,6 +78,36 @@ describe("fetchWithRetry", () => { expect(await response.text()).toBe("slow down"); expect(attempt).toBe(1); }); + + it("normalizes aborts during response backoff", async () => { + const request = fetchWithRetry("https://example.invalid/response-backoff", { + fetch: async () => new Response("retry", { status: 503 }), + signal: AbortSignal.timeout(10), + defaultDelayMs: 1_000, + maxAttempts: 2, + }); + + await expect(request).rejects.toMatchObject({ + name: "Error", + message: "Request was aborted", + }); + }); + + it("normalizes aborts during network-error backoff", async () => { + const request = fetchWithRetry("https://example.invalid/network-backoff", { + fetch: async () => { + throw new TypeError("connection reset"); + }, + signal: AbortSignal.timeout(10), + defaultDelayMs: 1_000, + maxAttempts: 2, + }); + + await expect(request).rejects.toMatchObject({ + name: "Error", + message: "Request was aborted", + }); + }); }); describe("extractRetryHint", () => {