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 <noreply@anthropic.com>
This commit is contained in:
Naveen Chatlapalli 2026-01-29 23:42:12 -06:00
parent da71eaebd2
commit fce6e08594
2 changed files with 93 additions and 10 deletions

View File

@ -282,17 +282,23 @@ export async function fetchFirecrawlContent(params: {
storeInCache: params.storeInCache, storeInCache: params.storeInCache,
}; };
const res = await fetch(endpoint, { let res: Response;
method: "POST", try {
headers: { res = await fetch(endpoint, {
Authorization: `Bearer ${params.apiKey}`, method: "POST",
"Content-Type": "application/json", headers: {
}, Authorization: `Bearer ${params.apiKey}`,
body: JSON.stringify(body), "Content-Type": "application/json",
signal: withTimeout(undefined, params.timeoutSeconds * 1000), },
}); 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; success?: boolean;
data?: { data?: {
markdown?: string; markdown?: string;
@ -306,6 +312,12 @@ export async function fetchFirecrawlContent(params: {
warning?: string; warning?: string;
error?: 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) { if (!res.ok || payload?.success === false) {
const detail = payload?.error || res.statusText; const detail = payload?.error || res.statusText;

View File

@ -269,4 +269,75 @@ describe("web_fetch extraction fallbacks", () => {
/Web fetch failed \(500\):.*Oops/, /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\)/);
});
}); });