moeru-ai/airi · error · Error
web search failed: tavily returned a non-JSON response
Error message
web search failed: tavily returned a non-JSON response
What it means
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.
Source
Thrown at packages/stage-ui/src/tools/web-search.ts:157
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 ?? '',
snippet: (result.content ?? '').slice(0, DEFAULT_RESULT_CHARS),
...(typeof result.score === 'number' ? { score: result.score } : {}),
...(result.published_date ? { ageHint: result.published_date } : {}),
}))
}
/**
* Renders results as a numbered list the model can read and cite. Each snippet
* is wrapped as untrusted content; the leading `[N] url` citations survive even
* if the model ignores the rest.View on GitHub (pinned to 27111382b4)
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.
Example fix
// before: opaque SyntaxError swallowed into the taxonomy
json = await response.json()
// -> web search failed: tavily returned a non-JSON response
// after (diagnostic): capture the body to find the culprit proxy page
const text = await response.text()
console.warn('[web_search] non-JSON 2xx body (first 300 chars):', text.slice(0, 300))
json = JSON.parse(text) Defensive patterns
Strategy: validation
Validate before calling
// After reading the body, validate it is JSON-shaped before relying on it.
const text = await response.text()
if (!text || text[0] !== '{') {
// A 2xx body that does not start with '{' is a proxy/maintenance page.
throw new Error('web search failed: tavily returned a non-JSON response')
}
const json = JSON.parse(text) Try / catch
// Treat a non-JSON 2xx as a transient/infra failure: log once and retry once.
async function readTavilyJson(response: Response) {
try {
return await response.json()
}
catch {
const body = (await response.text().catch(() => '')).slice(0, 200)
console.warn('[web_search] non-JSON 2xx body:', body)
throw new Error('web search failed: tavily returned a non-JSON response')
}
} Prevention
- 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.
When it happens
Trigger: 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.
Common situations: 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.
Related errors
- web search failed: tavily ${response.status}${detail ? `: ${
- No candidate server channel URL was reachable. ${errors.join
- Token exchange failed (${response.status}): ${text}
- No reachable private LAN address is available for the curren
- Failed to fetch image: ${response.statusText}
AI-assisted analysis of moeru-ai/airi@27111382b4 (2026-08-12).
Data as JSON: /api/errors/8bc26758283a9a4f.
Report an issue: GitHub.