fix(utils): normalized aborts during retry backoff

Route both fetchWithRetry backoff paths through one abort-normalizing wait helper and cover response and network retry cancellation.

Fixes #8450
This commit is contained in:
roboomp
2026-08-13 17:29:25 +00:00
committed by can1357
parent 6fc50c3e41
commit 5a0b2d460d
3 changed files with 45 additions and 2 deletions
+4
View File
@@ -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
+11 -2
View File
@@ -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<void> {
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") {
+30
View File
@@ -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", () => {