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
- 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
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
- 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
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
- Exa search failed HTTP ${resp.status}: ${text.slice(0, 120)}
- Exa discovery cooldown is open for "${canonicalName}"
- Exa returned no content for ${url}
- Exa returned malformed structured summary for ${url}
- Exa ${endpoint.slice(1)} failed HTTP ${response.status}: ${d
AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21).
Data as JSON: /api/errors/fc3546871f36b0ae.
Report an issue: GitHub.