diff --git a/src/web-search/exa-executor.ts b/src/web-search/exa-executor.ts index 0f6b0f6b79..88ae0d8414 100644 --- a/src/web-search/exa-executor.ts +++ b/src/web-search/exa-executor.ts @@ -10,9 +10,10 @@ */ import { fetchWithResetRetry } from "../lib/upstream-retry"; import { signalWithTimeout } from "../lib/abort"; +import { readBoundedResponseBytes } from "../lib/bounded-body"; import { sidecarEnter } from "../lib/sidecar-tracker"; import { redactSecretString } from "../lib/redact"; -import type { WebSearchSource } from "./parse"; +import { MAX_SIDECAR_RESPONSE_BYTES, type WebSearchSource } from "./parse"; import type { SidecarOutcome, SidecarSettings } from "./executor"; const EXA_SEARCH_URL = "https://api.exa.ai/search"; @@ -47,13 +48,26 @@ export async function runExaWebSearch( }), { abortSignal: linkedSignal.signal, label: "exa-web-search-sidecar" }, ); + const bounded = await readBoundedResponseBytes(res, { + maxBytes: MAX_SIDECAR_RESPONSE_BYTES, + signal: linkedSignal.signal, + }); + if (bounded.oversized) { + const prefix = res.ok ? "exa sidecar response" : `exa sidecar HTTP ${res.status} response`; + return { text: "", sources: [], error: `${prefix} exceeded byte bound` }; + } + const text = new TextDecoder("utf-8", { fatal: true }).decode(bounded.bytes); if (!res.ok) { - const t = await res.text().catch(() => ""); // Scrub BEFORE truncating: slicing first can cut the literal key at the // boundary, leaving an unscrubbable key prefix in the surviving text. - return { text: "", sources: [], error: `exa sidecar HTTP ${res.status}: ${scrub(t).slice(0, 200)}` }; + return { text: "", sources: [], error: `exa sidecar HTTP ${res.status}: ${scrub(text).slice(0, 200)}` }; + } + let payload: unknown = null; + try { + payload = JSON.parse(text); + } catch { + // The mapper owns the stable malformed/empty JSON outcome. } - const payload = await res.json().catch(() => null); return mapExaSearchResponse(payload); } catch (e) { const kind = e instanceof Error && e.name === "TimeoutError" ? "timeout" : "connect_error"; diff --git a/tests/exa-web-search.test.ts b/tests/exa-web-search.test.ts index a47aaa442d..19462bdd05 100644 --- a/tests/exa-web-search.test.ts +++ b/tests/exa-web-search.test.ts @@ -6,6 +6,7 @@ import { runWithWebSearch, type WebSearchLoopDeps } from "../src/web-search/loop import { createTestTranslatorBudget } from "./helpers/translator-budget"; import type { AdapterEvent, ProviderAdapter } from "../src/adapters/base"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { MAX_SIDECAR_RESPONSE_BYTES } from "../src/web-search/parse"; const routed: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://routed.test/v1", apiKey: "k" }; function config(overrides: Partial = {}): OcxConfig { @@ -98,6 +99,28 @@ describe("runExaWebSearch key hygiene (canary)", () => { globalThis.fetch = realFetch; } }); + + test.each([ + ["success", 200], + ["error", 500], + ] as const)("oversized %s body is rejected and cancelled", async (_branch, status) => { + let bodyCancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_SIDECAR_RESPONSE_BYTES + 1).fill(0x61)); + }, + cancel() { bodyCancelled = true; }, + }); + const realFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(body, { status })) as typeof fetch; + try { + const out = await runExaWebSearch("q", "key-1", { model: "m", reasoning: "low", timeoutMs: 5000, describeImages: false }); + expect(out.error).toContain("byte bound"); + expect(bodyCancelled).toBe(true); + } finally { + globalThis.fetch = realFetch; + } + }); }); describe("planWebSearch exa arm (L9)", () => {