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

Tavily returned 2xx but response.json() failed — the body was not valid JSON, typically an HTML error/proxy page or a truncated response. The tool keeps the 'web search failed: tavily' taxonomy so callers handle it like other search failures instead of seeing an opaque SyntaxError.

Solutions

  1. Retry once — truncated or corrupted responses are frequently transient.
  2. Log the response content-type; text/html confirms proxy/CDN interception.
  3. Bypass the proxy for api.tavily.com or add an exception rule.
  4. If persistent, capture the first bytes of the body to identify the interceptor.
Defensive patterns

Strategy: retry

Validate before calling

const contentType = response.headers.get('content-type') ?? ''
if (response.ok && !contentType.includes('application/json')) {
  // treat as intercepted/proxied response before parsing
}

Try / catch

for (let attempt = 0; attempt < 2; attempt++) {
  try {
    return await parseTavilyResponse(response)
  }
  catch (e) {
    if (attempt === 1 || !errorMessageFrom(e)?.includes('non-JSON'))
      throw e
    response = await refetch()
  }
}

Prevention

When it happens

Trigger: An intercepting proxy or captive portal returning an HTML page with status 200; a CDN edge error page; a truncated body on a flaky connection.

Common situations: Corporate proxy/VPN TLS interception; cloudflare-style 200 error pages; mobile networks truncating long responses.

Understand the failure class

Background: "Invalid JSON response" and "Failed to parse response" errors: when an API answers 200 but the body isn't the JSON your library expected — this error's family across 28 libraries.

Related errors


AI-assisted analysis of moeru-ai/airi@b6d0809ecb (2026-08-18). Data as JSON: /api/errors/8bc26758283a9a4f. Report an issue: GitHub.

Appendix: source

Thrown at packages/stage-ui/src/tools/web-search.ts:153

    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 b6d0809ecb)