From 7de89a2c8500ef37aad6da616935a7a2518994a0 Mon Sep 17 00:00:00 2001 From: Ren Koya Date: Mon, 10 Aug 2026 00:36:57 +0900 Subject: [PATCH] fix: apply webFetch timeout to the response body The 30s timeout was cleared as soon as fetch() resolved, which is when headers arrive. A response whose body then stalled was read with no deadline at all, so the agent's webFetch tool call could hang until the Worker itself gave out. Keep the timer armed across the body read and clear it in one finally, so an abort mid-body surfaces as the same "Fetch timed out" error. --- .../__tests__/web-fetch.test.ts | 26 ++++++++++ packages/workshop-backend/src/web-fetch.ts | 48 +++++++++++-------- 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/workshop-backend/__tests__/web-fetch.test.ts b/packages/workshop-backend/__tests__/web-fetch.test.ts index 275ef6a58..983b8ca53 100644 --- a/packages/workshop-backend/__tests__/web-fetch.test.ts +++ b/packages/workshop-backend/__tests__/web-fetch.test.ts @@ -261,6 +261,32 @@ describe("webFetch document conversion", () => { // only cares that the call didn't go through toMarkdown. expect(typeof result.body).toBe("string"); }); + + it("times out a response whose body stalls after the headers arrive", async () => { + vi.useFakeTimers(); + try { + // Headers arrive at once; the body yields one chunk and then never completes, as the + // runtime would present a stalled origin. Only an abort ends the read. + globalThis.fetch = vi.fn(async (_url: string, init: RequestInit) => + new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("partial")); + init.signal?.addEventListener("abort", () => + controller.error(Object.assign(new Error("aborted"), { name: "AbortError" }))); + }, + }), { headers: { "content-type": "text/plain" } }), + ) as unknown as typeof globalThis.fetch; + + // Assert before advancing: the rejection lands inside advanceTimersByTimeAsync(). + const settled = expect( + webFetch(makeEnv(), { url: "https://example.com/slow", raw: true }), + ).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(60_000); + await settled; + } finally { + vi.useRealTimers(); + } + }); }); describe("formatWebFetchResult", () => { diff --git a/packages/workshop-backend/src/web-fetch.ts b/packages/workshop-backend/src/web-fetch.ts index be4bd7e6d..a2ec23c28 100644 --- a/packages/workshop-backend/src/web-fetch.ts +++ b/packages/workshop-backend/src/web-fetch.ts @@ -272,6 +272,12 @@ export async function webFetch( const timeoutId = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS); let response: Response; + let finalUrl: URL; + let contentType: string; + let bytes: Uint8Array; + let truncated: boolean; + // The timeout covers the body too: a response whose headers arrive promptly but whose body + // then stalls would otherwise hold the tool call open indefinitely. try { response = await fetch(parsed.toString(), { method: "GET", @@ -282,6 +288,27 @@ export async function webFetch( }, signal: abortController.signal, }); + + // `response.url` is set by the runtime to the final URL after any redirects. Fall back + // to the original URL if it happens to be empty. + finalUrl = response.url ? new URL(response.url) : parsed; + contentType = response.headers.get("content-type") ?? ""; + + // Respect the Content-Signal header (https://contentsignals.org/). If the site + // explicitly sets `ai-input=no`, we must not feed its content to the AI agent. + if (contentSignalDenies(response, "ai-input")) { + try { + await response.body?.cancel(); + } catch { + // Ignore. + } + throw new Error( + `The site at ${finalUrl} sets Content-Signal: ai-input=no, indicating that ` + + `it does not permit its content to be used as AI input.`, + ); + } + + ({ bytes, truncated } = await readBodyCapped(response, maxBytes)); } catch (err) { if ( err instanceof Error && @@ -294,27 +321,6 @@ export async function webFetch( clearTimeout(timeoutId); } - // `response.url` is set by the runtime to the final URL after any redirects. Fall back - // to the original URL if it happens to be empty. - const finalUrl = response.url ? new URL(response.url) : parsed; - const contentType = response.headers.get("content-type") ?? ""; - - // Respect the Content-Signal header (https://contentsignals.org/). If the site - // explicitly sets `ai-input=no`, we must not feed its content to the AI agent. - if (contentSignalDenies(response, "ai-input")) { - try { - await response.body?.cancel(); - } catch { - // Ignore. - } - throw new Error( - `The site at ${finalUrl} sets Content-Signal: ai-input=no, indicating that ` + - `it does not permit its content to be used as AI input.`, - ); - } - - const { bytes, truncated } = await readBodyCapped(response, maxBytes); - let body: string; if (input.raw) { body = decodeUtf8(bytes);