moeru-ai/airi · error · Error

web search failed: tavily

Error message

web search failed: tavily ${response.status}${detail ? `: ${detail}` : ''}

What it means

The web-search tool called Tavily's REST endpoint with the configured bearer key and got a non-2xx; the message embeds the status plus up to 200 chars of body so the model and logs see the upstream reason without payload dumps.

Solutions

  1. Check the Tavily key in provider settings and re-save it cleanly (no whitespace).
  2. Verify quota/billing on the Tavily dashboard.
  3. Classify by embedded status: 401/403 = key, 429/432 = quota, 400 = arguments.
  4. Retry with backoff only for 429/5xx.
Defensive patterns

Strategy: try-catch

Validate before calling

const apiKey = providerConfig.apiKey?.trim()
if (!apiKey) {
  return { results: [], error: 'Tavily API key not configured' }
}

Try / catch

try {
  const results = await webSearchTavily(query, { apiKey, signal })
}
catch (e) {
  const msg = errorMessageFrom(e) ?? ''
  if (msg.includes('tavily 401'))
    configureProvider()
  else if (msg.includes('tavily 429') || msg.includes('tavily 432'))
    await delay(60_000) // then retry once
  else
    throw e
}

Prevention

When it happens

Trigger: 401/403 invalid or revoked Tavily API key; 429 (or 432) quota/rate limit exhausted; 400 malformed query arguments; region-blocked access.

Common situations: Key pasted with trailing whitespace/newline; expired free-tier quota; one key shared across environments hitting rate limits; a proxy in front of api.tavily.com rejecting the request.

Understand the failure class

Background: "API error: {status}" and "HTTP 401/403/404/429/5xx" errors: non-2xx HTTP responses explained — this error's family across 27 libraries.

Related errors


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

Appendix: source

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

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