moeru-ai/airi · error · Error
web search failed: tavily ${response.status}${detail ? `: ${
Error message
web search failed: tavily ${response.status}${detail ? `: ${detail}` : ''} What it means
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).
Source
Thrown at packages/stage-ui/src/tools/web-search.ts:147
body.include_domains = input.include_domains
if (input.exclude_domains?.length)
body.exclude_domains = input.exclude_domains
const response = await fetch(TAVILY_SEARCH_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
// Slice the body so a failing endpoint never dumps a full payload into the
// model context or logs.
const detail = (await response.text().catch(() => '')).slice(0, 200)
throw new Error(`web search failed: tavily ${response.status}${detail ? `: ${detail}` : ''}`)
}
// A 2xx with a non-JSON body (an HTML proxy/error page, a truncated response)
// would otherwise throw an opaque SyntaxError; surface it in the same taxonomy.
let json: { results?: Array<{ title?: string, url?: string, content?: string, score?: number, published_date?: string }> }
try {
json = await response.json()
}
catch {
throw new Error('web search failed: tavily returned a non-JSON response')
}
// Guard the shape before mapping: a 2xx whose `results` is missing or not an
// array is treated as "no results" rather than throwing on `.map`.
const results = Array.isArray(json.results) ? json.results : []
return results.map(result => ({
title: result.title ?? '',
url: result.url ?? '',View on GitHub (pinned to 27111382b4)
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.
Example fix
// before: stale or empty key passed through
const tools = await createWebSearchTools({ apiKey: storedKey })
// -> web search failed: tavily 401: Unauthorized
// after: gate mounting on a configured, trimmed key
const apiKey = storedKey?.trim()
if (!apiKey) return [] // omit web_search entirely
return await createWebSearchTools({ apiKey }) Defensive patterns
Strategy: try-catch
Validate before calling
// Validate the key shape/length before mounting the tool.
const apiKey = options.apiKey?.trim()
if (!apiKey || apiKey.length < 20) {
// Tavily keys are long; a short/empty value will 401 — do not mount.
return []
}
return await createWebSearchTools({ apiKey, timeoutMs: options.timeoutMs }) Try / catch
// Keep tool execution resilient: catch Tavily failures and return a model-facing
// error string instead of letting the tool reject the whole turn.
try {
const results = await searchTavily(apiKey, input, maxResults, signal)
return formatResults(input.query, results)
}
catch (error) {
const msg = errorMessageFromValue(error)
if (msg.startsWith('web search failed: tavily ')) {
// provider failure — surface to the model, do not retry automatically.
return `Web search is unavailable right now (${msg}). Answer from existing knowledge or tell the user to check the Tavily key/quota.`
}
throw error
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- OpenRouter audio request failed: ${response.status} ${await
- Streaming transcription request failed with status ${respons
- web search failed: tavily returned a non-JSON response
- Auth request failed (${response.status})
- Failed to fetch characters
AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12).
Data as JSON: /api/errors/f492f64635d18937.
Report an issue: GitHub.