{"record":{"id":"f492f64635d18937","repo":"moeru-ai/airi","slug":"web-search-failed-tavily-response-status-deta","errorCode":null,"errorMessage":"web search failed: tavily ${response.status}${detail ? `: ${detail}` : ''}","messagePattern":"web search failed: tavily (.+?)(.+?)` : ''\\}","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/stage-ui/src/tools/web-search.ts","lineNumber":147,"sourceCode":"    body.include_domains = input.include_domains\n  if (input.exclude_domains?.length)\n    body.exclude_domains = input.exclude_domains\n\n  const response = await fetch(TAVILY_SEARCH_URL, {\n    method: 'POST',\n    headers: {\n      'content-type': 'application/json',\n      'authorization': `Bearer ${apiKey}`,\n    },\n    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 ?? '',","sourceCodeStart":129,"sourceCodeEnd":165,"githubUrl":"https://github.com/moeru-ai/airi/blob/27111382b4a79a7e983289d6e983a06af185ed0f/packages/stage-ui/src/tools/web-search.ts#L129-L165","documentation":"Thrown by searchTavily() (web-search.ts:147) when the Tavily POST to https://api.tavily.com/search returns a non-2xx status. The body is read and sliced to 200 characters so a failing endpoint cannot flood the model context or logs; the HTTP status and that detail slice are concatenated into the message. This is the single error taxonomy for transport-level Tavily failures (auth, quota, bad request, upstream outage).","triggerScenarios":"Calling the web_search tool with an invalid/expired/revoked Tavily API key (401/403), exceeding the Tavily plan's rate limit or monthly quota (429), sending a malformed request body (400, though the schema normally prevents this), or hitting a Tavily/CDN outage (5xx). Also reachable if a corporate proxy returns its own non-2xx status for api.tavily.com.","commonSituations":"The Tavily key was rotated in the dashboard but the new value was not saved into the web-search module settings; the free-tier quota was exhausted mid-session; a stale key leaked into production env; a proxy/firewall blocks or rewrites the request to api.tavily.com.","solutions":["Read the HTTP status in the message: 401/403 → regenerate and re-enter the Tavily API key in the web-search settings; 429 → wait and/or upgrade the Tavily plan; 5xx → retry after a short backoff.","Confirm the key is being passed into createWebSearchTools({ apiKey }) and that no leading/trailing whitespace or 'Bearer ' prefix was accidentally included (the code already adds the Bearer prefix at web-search.ts:137).","Verify network egress to https://api.tavily.com/search is not blocked or rewritten by a proxy.","Surface the error to the user (via the tool result) so they know the web-search provider rejected the request, and offer to update the key in settings."],"exampleFix":"// before: stale or empty key passed through\nconst tools = await createWebSearchTools({ apiKey: storedKey })\n// -> web search failed: tavily 401: Unauthorized\n\n// after: gate mounting on a configured, trimmed key\nconst apiKey = storedKey?.trim()\nif (!apiKey) return [] // omit web_search entirely\nreturn await createWebSearchTools({ apiKey })","handlingStrategy":"try-catch","validationCode":"// Validate the key shape/length before mounting the tool.\nconst apiKey = options.apiKey?.trim()\nif (!apiKey || apiKey.length < 20) {\n  // Tavily keys are long; a short/empty value will 401 — do not mount.\n  return []\n}\nreturn await createWebSearchTools({ apiKey, timeoutMs: options.timeoutMs })","typeGuard":null,"tryCatchPattern":"// Keep tool execution resilient: catch Tavily failures and return a model-facing\n// error string instead of letting the tool reject the whole turn.\ntry {\n  const results = await searchTavily(apiKey, input, maxResults, signal)\n  return formatResults(input.query, results)\n}\ncatch (error) {\n  const msg = errorMessageFromValue(error)\n  if (msg.startsWith('web search failed: tavily ')) {\n    // provider failure — surface to the model, do not retry automatically.\n    return `Web search is unavailable right now (${msg}). Answer from existing knowledge or tell the user to check the Tavily key/quota.`\n  }\n  throw error\n}","preventionTips":["Trim and length-check the Tavily key before passing it to createWebSearchTools so a blank/stale value does not mount a tool that can only 401.","Surface 401/403 and 429 distinctly to the user (key vs quota) so they know which to fix.","Keep the outbound timeout (createWebSearchTools timeoutMs) bounded so a hung endpoint fails the tool rather than the turn."],"tags":["network","http","tavily","web-search","api-key","provider"],"backgroundTag":null,"analyzedSha":"27111382b4a79a7e983289d6e983a06af185ed0f","analyzedAt":"2026-08-12T18:33:34.132Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}