{"record":{"id":"8bc26758283a9a4f","repo":"moeru-ai/airi","slug":"web-search-failed-tavily-returned-a-non-json-resp","errorCode":null,"errorMessage":"web search failed: tavily returned a non-JSON response","messagePattern":"web search failed: tavily returned a non-JSON response","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui/src/tools/web-search.ts","lineNumber":157,"sourceCode":"    body: JSON.stringify(body),\n    signal,\n  })\n\n  if (!response.ok) {\n    // Slice the body so a failing endpoint never dumps a full payload into the\n    // model context or logs.\n    const detail = (await response.text().catch(() => '')).slice(0, 200)\n    throw new Error(`web search failed: tavily ${response.status}${detail ? `: ${detail}` : ''}`)\n  }\n\n  // A 2xx with a non-JSON body (an HTML proxy/error page, a truncated response)\n  // would otherwise throw an opaque SyntaxError; surface it in the same taxonomy.\n  let json: { results?: Array<{ title?: string, url?: string, content?: string, score?: number, published_date?: string }> }\n  try {\n    json = await response.json()\n  }\n  catch {\n    throw new Error('web search failed: tavily returned a non-JSON response')\n  }\n\n  // Guard the shape before mapping: a 2xx whose `results` is missing or not an\n  // array is treated as \"no results\" rather than throwing on `.map`.\n  const results = Array.isArray(json.results) ? json.results : []\n  return results.map(result => ({\n    title: result.title ?? '',\n    url: result.url ?? '',\n    snippet: (result.content ?? '').slice(0, DEFAULT_RESULT_CHARS),\n    ...(typeof result.score === 'number' ? { score: result.score } : {}),\n    ...(result.published_date ? { ageHint: result.published_date } : {}),\n  }))\n}\n\n/**\n * Renders results as a numbered list the model can read and cite. Each snippet\n * is wrapped as untrusted content; the leading `[N] url` citations survive even\n * if the model ignores the rest.","sourceCodeStart":139,"sourceCodeEnd":175,"githubUrl":"https://github.com/moeru-ai/airi/blob/27111382b4a79a7e983289d6e983a06af185ed0f/packages/stage-ui/src/tools/web-search.ts#L139-L175","documentation":"Thrown by searchTavily() (web-search.ts:157) when Tavily returns a 2xx status but the body cannot be parsed as JSON. The explicit catch converts what would otherwise be an opaque SyntaxError from response.json() into the same 'web search failed: tavily ...' taxonomy so callers see a consistent failure shape. A 2xx with HTML/text typically means an intermediary returned its own page instead of the API payload.","triggerScenarios":"A corporate proxy, captive portal, or transparent CDN returns an HTML error/login page with a 200 status for api.tavily.com; a truncated response body from a flaky upstream; a misconfigured reverse proxy or gateway in front of the Tavily endpoint serving a maintenance page; response body gzip/encoding mismatch producing non-JSON bytes.","commonSituations":"Running on a network with mandatory proxy auth that serves an HTML login page for unauthenticated egress; deploying behind a corporate firewall whose SSL-inspection appliance rewrites responses; intermittent upstream issues that close the connection mid-body.","solutions":["Inspect the actual response body: capture it (e.g. via a network log or by temporarily logging response.text()) to identify the proxy/maintenance page responsible.","Bypass or whitelist api.tavily.com in any corporate proxy / SSL-inspection / captive-portal layer so the request reaches the real API.","If intermittent, retry once with a short backoff — a truncated body is often transient.","Verify the request Content-Type/Accept headers and that no proxy is altering the response encoding."],"exampleFix":"// before: opaque SyntaxError swallowed into the taxonomy\njson = await response.json()\n// -> web search failed: tavily returned a non-JSON response\n\n// after (diagnostic): capture the body to find the culprit proxy page\nconst text = await response.text()\nconsole.warn('[web_search] non-JSON 2xx body (first 300 chars):', text.slice(0, 300))\njson = JSON.parse(text)","handlingStrategy":"validation","validationCode":"// After reading the body, validate it is JSON-shaped before relying on it.\nconst text = await response.text()\nif (!text || text[0] !== '{') {\n  // A 2xx body that does not start with '{' is a proxy/maintenance page.\n  throw new Error('web search failed: tavily returned a non-JSON response')\n}\nconst json = JSON.parse(text)","typeGuard":null,"tryCatchPattern":"// Treat a non-JSON 2xx as a transient/infra failure: log once and retry once.\nasync function readTavilyJson(response: Response) {\n  try {\n    return await response.json()\n  }\n  catch {\n    const body = (await response.text().catch(() => '')).slice(0, 200)\n    console.warn('[web_search] non-JSON 2xx body:', body)\n    throw new Error('web search failed: tavily returned a non-JSON response')\n  }\n}","preventionTips":["Whitelist api.tavily.com in any corporate proxy/SSL-inspection layer so it cannot return its own HTML page with a 2xx.","Log the first ~200 chars of a non-JSON body when this fires so the culprit proxy/maintenance page is identifiable.","Treat this error as infra/transient: retry once with backoff before giving up on the tool."],"tags":["network","proxy","tavily","web-search","parsing","response-shape"],"backgroundTag":null,"analyzedSha":"27111382b4a79a7e983289d6e983a06af185ed0f","analyzedAt":"2026-08-12T18:33:34.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}