{"record":{"id":"fc3546871f36b0ae","repo":"koala73/worldmonitor","slug":"exa-search-failed-for-canonicalname-detail","errorCode":null,"errorMessage":"Exa search failed for \"${canonicalName}\": ${detail}","messagePattern":"Exa search failed for \"(.+?)\": (.+?)","errorType":"exception","errorClass":"SearchTargetError","httpStatus":null,"severity":"error","filePath":"consumer-prices-core/src/adapters/search.ts","lineNumber":706,"sourceCode":"    let exaResults: SearchResult[];\n    try {\n      exaResults = await this.exa.search(discoveryRequest.query, discoveryRequest.options);\n      if (this.exaDiscoveryGate.recordSuccess()) {\n        ctx.logger.info(\n          `  [search:provider-cooldown] ${ctx.config.slug}: Exa discovery recovered — cooldown closed after a successful probe`,\n        );\n      }\n    } catch (err) {\n      const detail = err instanceof Error ? err.message : String(err);\n      if (this.exaDiscoveryGate.recordFailure()) {\n        ctx.logger.warn(\n          `  [search:provider-cooldown] ${ctx.config.slug}: Exa discovery cooling down after consecutive errors — skipping a bounded window, then probing (last: ${detail})`,\n        );\n      }\n      // Carry `detail` into the message: nothing downstream reads `.failures`,\n      // so without it the log cannot tell an auth failure from a rate limit\n      // from a timeout — the distinction the cooldown exists to surface.\n      throw new SearchTargetError(`Exa search failed for \"${canonicalName}\": ${detail}`, 0, [\n        { provider: 'exa', reason: 'provider-error', detail },\n      ]);\n    }\n\n    const pathFilters = normalizePathFilters(cfg?.urlPathContains);\n    const requiredSegments = cfg?.urlPathMustContain ?? [];\n    const attemptedUrls = direct ? new Set([target.url]) : new Set<string>();\n    const filterInPolicy = (results: SearchResult[]) =>\n      results\n        .map((r) => r.url)\n        .filter(\n          (url) =>\n            !!url &&\n            isAllowedHost(url, hostAllowlist) &&\n            matchesAnyPathFilter(url, pathFilters) &&\n            matchesRequiredPathSegments(url, requiredSegments),\n        );\n    let discoveredUrls = filterInPolicy(exaResults);","sourceCodeStart":688,"sourceCodeEnd":724,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/consumer-prices-core/src/adapters/search.ts#L688-L724","documentation":"The search adapter's Exa discovery call failed and is re-thrown wrapped in SearchTargetError, with the underlying message preserved in both the text and failures[0].detail. The catch also feeds exaDiscoveryGate.recordFailure() — consecutive failures open the discovery cooldown (the 'Exa discovery cooldown is open' error) for subsequent targets. The code comment explains why detail is carried: nothing downstream reads .failures, so without the suffix logs cannot distinguish auth from rate limit from timeout.","triggerScenarios":"An Exa HTTP failure ('Exa search failed HTTP ...') during discovery for a canonicalName; network/AbortSignal timeouts; malformed responses — each becomes 'Exa search failed for \"<name>\": <detail>' with failures [{provider:'exa', reason:'provider-error', detail}].","commonSituations":"Exa rate limit at run start so the first targets fail and open the cooldown; an expired EXA_API_KEY producing auth detail on every item; a request-builder regression producing 400 details — the wrapped detail is the only clue, so check it before assuming outage.","solutions":["Read the detail suffix — HTTP 401/403 means credentials, 429 means back off, timeout means network, 400 means a request bug","Fix credentials or the request per the detail; the gate half-opens and probes automatically","On 429s, lower discovery concurrency or stagger basket runs","If the detail shows INVALID_REQUEST_BODY, fix the request builder — retries cannot fix a 400"],"exampleFix":"// before — detail swallowed; logs show only the wrapper\ncatch (err) { throw new SearchTargetError(`Exa search failed for '${name}'`, 0, []); }\n\n// after — carry the detail (matches shipped code)\ncatch (err) {\n  const detail = err instanceof Error ? err.message : String(err);\n  throw new SearchTargetError(`Exa search failed for '${name}': ${detail}`, 0,\n    [{ provider: 'exa', reason: 'provider-error', detail }]);\n}","handlingStrategy":"try-catch","validationCode":"// preflight Exa credentials before a basket run\nconst probe = 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 (probe.status === 401 || probe.status === 403) {\n  throw new Error('Exa credentials rejected — abort before the cooldown opens');\n}","typeGuard":"function isExaSearchTargetError(err: unknown): boolean {\n  return err instanceof SearchTargetError\n    && err.failures.some((f) => f.provider === 'exa' && f.reason === 'provider-error');\n}","tryCatchPattern":"try {\n  await adapter.fetchTarget(ctx, target);\n} catch (err) {\n  if (isExaSearchTargetError(err)) {\n    const detail = err.failures.find((f) => f.reason === 'provider-error')?.detail ?? '';\n    if (/HTTP 429|HTTP 5\\d\\d|timeout/i.test(detail)) {\n      await sleep(backoffMs);\n      return await retry(target); // transient\n    }\n    throw err; // auth/request bugs are permanent — surface, do not retry\n  }\n  throw err;\n}","preventionTips":["Always log the failures[].detail field — it is the only auth-vs-ratelimit-vs-timeout signal","Watch for consecutive failures: two open the discovery cooldown for every later target","Probe credentials at run start; one cheap call prevents a whole run of wrapped errors"],"tags":["exa","search-api","error-wrapping","discovery"],"backgroundTag":"provider-api-error","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}