{"record":{"id":"0a06eddc25d8aad7","repo":"koala73/worldmonitor","slug":"exa-discovery-cooldown-is-open-for-canonicalnam","errorCode":null,"errorMessage":"Exa discovery cooldown is open for \"${canonicalName}\"","messagePattern":"Exa discovery cooldown is open for \"(.+?)\"","errorType":"exception","errorClass":"SearchTargetError","httpStatus":null,"severity":"warning","filePath":"consumer-prices-core/src/adapters/search.ts","lineNumber":669,"sourceCode":"    // probe (the probe call itself happens inside _extractFromUrl).\n    if (ctx.config.searchConfig?.extractionFallback !== 'exa' && this.firecrawlGate.consumeSkip()) {\n      throw new SearchTargetError(\n        `Firecrawl extraction cooldown is open for \"${canonicalName}\"`,\n        0,\n        [{ provider: 'firecrawl', reason: 'provider-cooldown' }],\n      );\n    }\n\n    // Only the DISCOVERY cooldown can abort the target: Exa is the sole URL\n    // discovery provider, so without it there is nothing to extract from. An\n    // Exa *extraction* cooldown must not abort — Firecrawl is the primary\n    // extractor and is frequently healthy at that moment (its own streak resets\n    // on every success), and `_extractFromUrl` already skips the cooled-down\n    // provider per candidate. Aborting here would turn a fallback outage into\n    // a whole-basket loss, which is the COVERAGE_PARTIAL this adapter exists\n    // to prevent.\n    if (this.exaDiscoveryGate.consumeSkip()) {\n      throw new SearchTargetError(\n        `Exa discovery cooldown is open for \"${canonicalName}\"`,\n        0,\n        [{ provider: 'exa', reason: 'provider-cooldown' }],\n      );\n    }\n\n    const marketName = MARKET_NAMES[ctx.config.marketCode] ?? ctx.config.marketCode.toUpperCase();\n    const cfg = ctx.config.searchConfig;\n    const discoveryRequest = buildExaDiscoveryRequest({\n      searchConfig: cfg,\n      canonicalName,\n      category: target.category,\n      currency,\n      marketName,\n      includeDomains: hostAllowlist,\n    });\n\n    // Stage 1: Exa URL discovery","sourceCodeStart":651,"sourceCodeEnd":687,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/consumer-prices-core/src/adapters/search.ts#L651-L687","documentation":"The Exa discovery cooldown gate (exaDiscoveryGate) opened after consecutive Exa search failures, and because Exa is the sole URL discovery provider, the target aborts immediately with failures [{provider:'exa', reason:'provider-cooldown'}] and durationMs 0. Unlike the Firecrawl gate, this abort is intentional coverage loss: without Exa there are no candidate URLs to extract from, so failing fast beats hammering a dead provider. The comment notes an Exa extraction cooldown must NOT abort this way — only discovery does.","triggerScenarios":"Two or more consecutive Exa discovery failures (HTTP errors, timeouts) open exaDiscoveryGate; each target inside the skip window throws this at entry; a failed probe re-opens the gate for another window.","commonSituations":"An Exa rate limit or outage during basket discovery — the run logs one provider-cooldown per item and finishes COVERAGE_PARTIAL by design; operators mistake these for per-item errors and immediately re-run, which keeps hammering the dead provider.","solutions":["Treat as flow control: record items as discovery-skipped and let the run finish (COVERAGE_PARTIAL is the designed outcome)","Fix the underlying Exa condition — the '[search:provider-cooldown]' warn fired when the gate opened carries the last error detail","Wait out the skip window before re-running; the half-open probe tests recovery","Reduce Exa pressure (concurrency, query volume) if 429s keep re-opening the gate"],"exampleFix":"// before — cooldown aborts counted as coverage failures\nconst failed = results.filter((r) => r.error);\n\n// after — exclude provider-cooldown aborts from coverage math\nconst isCooldown = (r) => r.failures?.some((f) => f.reason === 'provider-cooldown');\nconst failed = results.filter((r) => r.error && !isCooldown(r));\nconst coverage = parsed / (total - results.filter(isCooldown).length);","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isExaDiscoveryCooldown(err: unknown): boolean {\n  return err instanceof SearchTargetError\n    && err.failures.some((f) => f.provider === 'exa' && f.reason === 'provider-cooldown');\n}","tryCatchPattern":"try {\n  await adapter.fetchTarget(ctx, target);\n} catch (err) {\n  if (isExaDiscoveryCooldown(err)) {\n    stats.discoverySkipped++; // expected under an Exa outage — COVERAGE_PARTIAL by design\n    continue;\n  }\n  throw err;\n}","preventionTips":["Alert on the gate-opening warn line, not on every per-item cooldown abort","Back off re-runs while the gate is open — immediate re-runs defeat the half-open probe","Track how often exaDiscoveryGate opens: recurring opens point at quota or concurrency, not luck"],"tags":["circuit-breaker","cooldown","exa","discovery","flow-control"],"backgroundTag":"circuit-breaker-open","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-08-23T16:17:53.355Z"}