From b913b7b90277344ddf425575b62cef998e0fb1a3 Mon Sep 17 00:00:00 2001 From: maximilliangrand <214999687+maximilliangrand@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:03:16 +0200 Subject: [PATCH] fix: give each retry its own timeout window The timeout signal was assigned onto `context.options.signal`, which is spread into the retry request. After a timeout aborts the signal, every subsequent retry reused that already aborted signal and aborted immediately instead of getting a fresh timeout window, so `retry` was effectively a no-op when combined with `timeout`. Derive the per-attempt timeout signal into the options passed to `fetch` without persisting it on `context.options`, so each retry gets its own timeout. --- README.md | 2 ++ src/fetch.ts | 23 +++++++++++++---------- test/index.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index bf99be5d..0bc66e69 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ await ofetch("http://google.com/404", { }); ``` +When combined with auto retry, each attempt gets its own fresh `timeout` window. + ## ✔️ Type Friendly The response can be type assisted: diff --git a/src/fetch.ts b/src/fetch.ts index 10c91583..9aefbb94 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -168,20 +168,23 @@ export function createFetch(globalOptions: CreateFetchOptions = {}): $Fetch { let abortTimeout: NodeJS.Timeout | undefined; + // Derive a fresh timeout signal for each attempt without persisting it on + // `context.options.signal`. Otherwise a retry triggered by a timeout would + // reuse the already aborted signal and abort immediately instead of getting + // its own timeout window. + let fetchOptions = context.options as RequestInit; if (context.options.timeout) { - context.options.signal = context.options.signal - ? AbortSignal.any([ - AbortSignal.timeout(context.options.timeout), - context.options.signal, - ]) - : AbortSignal.timeout(context.options.timeout); + const timeoutSignal = AbortSignal.timeout(context.options.timeout); + fetchOptions = { + ...context.options, + signal: context.options.signal + ? AbortSignal.any([timeoutSignal, context.options.signal]) + : timeoutSignal, + } as RequestInit; } try { - context.response = await fetch( - context.request, - context.options as RequestInit - ); + context.response = await fetch(context.request, fetchOptions); } catch (error) { context.error = error as Error; if (context.options.onRequestError) { diff --git a/test/index.test.ts b/test/index.test.ts index 5ac20b07..4e08c19b 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -18,6 +18,8 @@ describe("ofetch", () => { const fetch = vi.spyOn(globalThis, "fetch"); + let timeoutThenOkCount = 0; + beforeAll(async () => { const app = new H3({ debug: true }) // .use(async (event) => { @@ -62,6 +64,14 @@ describe("ofetch", () => { resolve(new HTTPError({ status: 408 })); }, 1000 * 5); }); + }) + .all("/timeout-then-ok", async () => { + timeoutThenOkCount++; + // Slow enough to trip a short timeout only on the first attempt. + if (timeoutThenOkCount === 1) { + await new Promise((resolve) => setTimeout(resolve, 1000 * 5)); + } + return "ok"; }); listener = await serve(app, { port: 0, hostname: "localhost" }).ready(); @@ -344,6 +354,18 @@ describe("ofetch", () => { }); }); + it("retry after a timeout uses a fresh timeout for each attempt", async () => { + timeoutThenOkCount = 0; + // The first attempt times out; the retry must get its own timeout window + // (not reuse the already aborted signal) so it can succeed. + const result = await $fetch(getURL("timeout-then-ok"), { + timeout: 100, + retry: 2, + }); + expect(result).to.equal("ok"); + expect(timeoutThenOkCount).toBeGreaterThanOrEqual(2); + }); + it("deep merges defaultOptions", async () => { const _customFetch = $fetch.create({ query: {