koala73/worldmonitor · error · SearchTargetError

Exa search failed for "${canonicalName}": ${detail}

Error message

Exa search failed for "${canonicalName}": ${detail}

What it means

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.

Source

Thrown at consumer-prices-core/src/adapters/search.ts:706

    let exaResults: SearchResult[];
    try {
      exaResults = await this.exa.search(discoveryRequest.query, discoveryRequest.options);
      if (this.exaDiscoveryGate.recordSuccess()) {
        ctx.logger.info(
          `  [search:provider-cooldown] ${ctx.config.slug}: Exa discovery recovered — cooldown closed after a successful probe`,
        );
      }
    } catch (err) {
      const detail = err instanceof Error ? err.message : String(err);
      if (this.exaDiscoveryGate.recordFailure()) {
        ctx.logger.warn(
          `  [search:provider-cooldown] ${ctx.config.slug}: Exa discovery cooling down after consecutive errors — skipping a bounded window, then probing (last: ${detail})`,
        );
      }
      // Carry `detail` into the message: nothing downstream reads `.failures`,
      // so without it the log cannot tell an auth failure from a rate limit
      // from a timeout — the distinction the cooldown exists to surface.
      throw new SearchTargetError(`Exa search failed for "${canonicalName}": ${detail}`, 0, [
        { provider: 'exa', reason: 'provider-error', detail },
      ]);
    }

    const pathFilters = normalizePathFilters(cfg?.urlPathContains);
    const requiredSegments = cfg?.urlPathMustContain ?? [];
    const attemptedUrls = direct ? new Set([target.url]) : new Set<string>();
    const filterInPolicy = (results: SearchResult[]) =>
      results
        .map((r) => r.url)
        .filter(
          (url) =>
            !!url &&
            isAllowedHost(url, hostAllowlist) &&
            matchesAnyPathFilter(url, pathFilters) &&
            matchesRequiredPathSegments(url, requiredSegments),
        );
    let discoveredUrls = filterInPolicy(exaResults);

View on GitHub (pinned to eeab0a219f)

Solutions

  1. Read the detail suffix — HTTP 401/403 means credentials, 429 means back off, timeout means network, 400 means a request bug
  2. Fix credentials or the request per the detail; the gate half-opens and probes automatically
  3. On 429s, lower discovery concurrency or stagger basket runs
  4. If the detail shows INVALID_REQUEST_BODY, fix the request builder — retries cannot fix a 400

Example fix

// before — detail swallowed; logs show only the wrapper
catch (err) { throw new SearchTargetError(`Exa search failed for '${name}'`, 0, []); }

// after — carry the detail (matches shipped code)
catch (err) {
  const detail = err instanceof Error ? err.message : String(err);
  throw new SearchTargetError(`Exa search failed for '${name}': ${detail}`, 0,
    [{ provider: 'exa', reason: 'provider-error', detail }]);
}
Defensive patterns

Strategy: try-catch

Validate before calling

// preflight Exa credentials before a basket run
const probe = await fetch('https://api.exa.ai/search', {
  method: 'POST',
  headers: { 'x-api-key': key, 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: 'ping', numResults: 0 }),
});
if (probe.status === 401 || probe.status === 403) {
  throw new Error('Exa credentials rejected — abort before the cooldown opens');
}

Type guard

function isExaSearchTargetError(err: unknown): boolean {
  return err instanceof SearchTargetError
    && err.failures.some((f) => f.provider === 'exa' && f.reason === 'provider-error');
}

Try / catch

try {
  await adapter.fetchTarget(ctx, target);
} catch (err) {
  if (isExaSearchTargetError(err)) {
    const detail = err.failures.find((f) => f.reason === 'provider-error')?.detail ?? '';
    if (/HTTP 429|HTTP 5\d\d|timeout/i.test(detail)) {
      await sleep(backoffMs);
      return await retry(target); // transient
    }
    throw err; // auth/request bugs are permanent — surface, do not retry
  }
  throw err;
}

Prevention

When it happens

Trigger: 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}].

Common situations: 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.

Related errors


AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21). Data as JSON: /api/errors/fc3546871f36b0ae. Report an issue: GitHub.