From fce6e08594f419e6ebad2bad0252a23ef21f64ba Mon Sep 17 00:00:00 2001 From: Naveen Chatlapalli Date: Thu, 29 Jan 2026 23:42:12 -0600 Subject: [PATCH] fix(web-fetch): handle network-level fetch errors in fetchFirecrawlContent When fetch() fails at the network level (timeout, connection refused, etc.), the error was not being caught within fetchFirecrawlContent, causing unhandled promise rejections that could crash the gateway. This change wraps the fetch() and res.json() calls in try-catch blocks to: - Catch network-level fetch failures and throw a descriptive error - Catch invalid JSON response errors and throw a descriptive error The descriptive errors propagate up to the tool execution wrapper which converts them to proper tool error results. Fixes #4392 Co-Authored-By: Claude Opus 4.5 --- src/agents/tools/web-fetch.ts | 32 +++++++---- src/agents/tools/web-tools.fetch.test.ts | 71 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/src/agents/tools/web-fetch.ts b/src/agents/tools/web-fetch.ts index b127cd49f..b53147a3e 100644 --- a/src/agents/tools/web-fetch.ts +++ b/src/agents/tools/web-fetch.ts @@ -282,17 +282,23 @@ export async function fetchFirecrawlContent(params: { storeInCache: params.storeInCache, }; - const res = await fetch(endpoint, { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - signal: withTimeout(undefined, params.timeoutSeconds * 1000), - }); + let res: Response; + try { + res = await fetch(endpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: withTimeout(undefined, params.timeoutSeconds * 1000), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Firecrawl fetch failed (network error): ${message}`); + } - const payload = (await res.json()) as { + let payload: { success?: boolean; data?: { markdown?: string; @@ -306,6 +312,12 @@ export async function fetchFirecrawlContent(params: { warning?: string; error?: string; }; + try { + payload = (await res.json()) as typeof payload; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Firecrawl fetch failed (invalid response): ${message}`); + } if (!res.ok || payload?.success === false) { const detail = payload?.error || res.statusText; diff --git a/src/agents/tools/web-tools.fetch.test.ts b/src/agents/tools/web-tools.fetch.test.ts index 86bdeb7a2..7bd19aec8 100644 --- a/src/agents/tools/web-tools.fetch.test.ts +++ b/src/agents/tools/web-tools.fetch.test.ts @@ -269,4 +269,75 @@ describe("web_fetch extraction fallbacks", () => { /Web fetch failed \(500\):.*Oops/, ); }); + + it("handles network-level fetch failure in firecrawl fallback", async () => { + const mockFetch = vi.fn((input: RequestInfo) => { + const url = requestUrl(input); + if (url.includes("api.firecrawl.dev")) { + return Promise.reject(new TypeError("fetch failed")); + } + // Direct fetch fails with 403 + return Promise.resolve({ + ok: false, + status: 403, + headers: makeHeaders({ "content-type": "text/html" }), + text: async () => "blocked", + } as Response); + }); + // @ts-expect-error mock fetch + global.fetch = mockFetch; + + const tool = createWebFetchTool({ + config: { + tools: { + web: { + fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } }, + }, + }, + }, + sandboxed: false, + }); + + await expect( + tool?.execute?.("call", { url: "https://example.com/network-error" }), + ).rejects.toThrow(/Firecrawl fetch failed \(network error\)/); + }); + + it("handles invalid JSON response from firecrawl", async () => { + const mockFetch = vi.fn((input: RequestInfo) => { + const url = requestUrl(input); + if (url.includes("api.firecrawl.dev")) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token"); + }, + } as Response); + } + return Promise.resolve({ + ok: false, + status: 403, + headers: makeHeaders({ "content-type": "text/html" }), + text: async () => "blocked", + } as Response); + }); + // @ts-expect-error mock fetch + global.fetch = mockFetch; + + const tool = createWebFetchTool({ + config: { + tools: { + web: { + fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } }, + }, + }, + }, + sandboxed: false, + }); + + await expect( + tool?.execute?.("call", { url: "https://example.com/invalid-json" }), + ).rejects.toThrow(/Firecrawl fetch failed \(invalid response\)/); + }); });