{"record":{"id":"da358a6bbec36eaa","repo":"koala73/worldmonitor","slug":"exa-search-failed-http-resp-status-text-slic","errorCode":null,"errorMessage":"Exa search failed HTTP ${resp.status}: ${text.slice(0, 120)}","messagePattern":"Exa search failed HTTP (.+?): (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"consumer-prices-core/src/adapters/exa-search.ts","lineNumber":177,"sourceCode":"          query: `What is the retail price of this product? State amount and ISO currency code (e.g. ${currency} 12.50).`,\n        },\n      },\n    };\n\n    const resp = await fetch('https://api.exa.ai/search', {\n      method: 'POST',\n      headers: {\n        'x-api-key': this.apiKey,\n        'Content-Type': 'application/json',\n        'User-Agent': CHROME_UA,\n      },\n      body: JSON.stringify(body),\n      signal: AbortSignal.timeout(15_000),\n    });\n\n    if (!resp.ok) {\n      const text = await resp.text().catch(() => '');\n      throw new Error(`Exa search failed HTTP ${resp.status}: ${text.slice(0, 120)}`);\n    }\n\n    const data = (await resp.json()) as { results?: ExaResult[] };\n    const exaResults = data.results ?? [];\n\n    const payload: SearchPayload = {\n      exaResults,\n      basketSlug,\n      itemCategory: target.category,\n      canonicalName,\n    };\n\n    // Firecrawl fallback: when all Exa summaries fail price extraction,\n    // scrape the first result URL directly (JS-rendered pages expose prices in markdown).\n    const anyExaPrice = exaResults.some(\n      (r) => matchPrice(r.summary ?? '', currency) !== null || matchPrice(r.title ?? '', currency) !== null,\n    );\n","sourceCodeStart":159,"sourceCodeEnd":195,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/consumer-prices-core/src/adapters/exa-search.ts#L159-L195","documentation":"The exa-search adapter's raw Exa call (POST with x-api-key and a 15s AbortSignal timeout) received a non-2xx status; the message embeds both the code and the first 120 characters of the response body. Exa reports bad keys (401), rate limits (429), and validator rejections (400 INVALID_REQUEST_BODY) this way, and the body slice is the only place the actual reason appears.","triggerScenarios":"401 with a wrong EXA_API_KEY; 429 when the Exa rate limit is hit during basket discovery; 400 INVALID_REQUEST_BODY when the request JSON violates Exa's validator — notably a JSON Schema using array type unions, which Exa rejects but Firecrawl accepts (#6182); 5xx during an Exa incident.","commonSituations":"One Exa key shared across too many workers so 429s cluster at run start; porting a Firecrawl-validated schema to Exa verbatim so every call 400s; a key that expired after rotation.","solutions":["Read the embedded body slice first — it names the exact auth, quota, or validator complaint","401/403: fix EXA_API_KEY; 429: back off and reduce request concurrency","400: fix the request/schema shape — for Exa extraction schemas use anyOf for nullability, never type arrays","5xx: retry with backoff; Exa incidents usually clear within minutes"],"exampleFix":"// before — schema encoding Firecrawl accepts but Exa rejects (HTTP 400)\nconst schema = { price: { type: ['number', 'null'] } };\n\n// after — Exa-compatible nullable encoding\nconst schema = { price: { anyOf: [{ type: 'number' }, { type: 'null' }] } };","handlingStrategy":"retry","validationCode":"// cheap preflight: authenticated liveness call before the run\nconst resp = await fetch('https://api.exa.ai/search', {\n  method: 'POST',\n  headers: { 'x-api-key': key, 'Content-Type': 'application/json' },\n  body: JSON.stringify({ query: 'ping', numResults: 0 }),\n});\nif (resp.status === 401 || resp.status === 403) {\n  throw new Error('EXA_API_KEY rejected — fix credentials before the run');\n}","typeGuard":"function exaStatus(err: unknown): number | null {\n  const m = err instanceof Error ? err.message.match(/^Exa search failed HTTP (\\d+):/) : null;\n  return m ? Number(m[1]) : null;\n}","tryCatchPattern":"try {\n  await exaCall();\n} catch (err) {\n  const status = exaStatus(err);\n  if (status === 429 || (status !== null && status >= 500)) {\n    await sleep(backoffMs);\n    return await exaCall(); // transient\n  }\n  throw err; // 4xx (except 429) is a request bug — retrying cannot help\n}","preventionTips":["Always surface the 120-char body slice in logs — it distinguishes auth, quota, and schema errors","Encode nullable schema fields as anyOf for Exa; never share schema encodings between providers untested","Keep Exa concurrency low enough to stay under the plan's rate limit"],"tags":["exa","search-api","http-status","schema-validation"],"backgroundTag":"http-api-error","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}