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\)/); + }); });