fix(search): distinguish all-engines-failed from genuinely empty results - #263
fix(search): distinguish all-engines-failed from genuinely empty results#263shauneccles wants to merge 3 commits into
Conversation
`unresponsive_engines` was declared on SearXNG Web but never read, so a search where every engine that reported back failed returned the same successful "No results found" string as a search that ran cleanly and matched nothing. A calling agent could not tell a CAPTCHA or rate limit from an authoritative negative answer. Raise `createEngineFailureError` when the raw result set is empty and SearXNG reports unresponsive engines. The check reads `data.results` rather than the filtered set, so rows removed by `min_score` or `num_results` remain a filtering outcome and keep the existing message, and it sits above the `json` branch so both response formats are covered. Fixes ihor-sokoliuk#262 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBoX4ym1XSuM4cHHdZ4ufd
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Duplication | 0 |
NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6803270143
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // num_results filtered away are a filtering outcome and still deserve the | ||
| // plain no-results message. This sits above the json branch so both response | ||
| // formats are covered. | ||
| if (data.results.length === 0 && hasItems(data.unresponsive_engines)) { |
There was a problem hiding this comment.
Aggregate empty replica responses before classifying failure
When multiple interchangeable instances all return zero rows, performFailoverSearch and the all-empty fan-out path retain only successes[0].data, so this condition depends on replica ordering. If the first replica reports failed engines but a later replica completes cleanly with no matches, the request incorrectly throws despite the clean result; if every replica reports different failures, only the first replica's engines are named. Classify the aggregate empty responses instead, treating the search as failed only when none completed cleanly and combining the applicable engine failures.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid finding — this was a real ordering dependency, and I've reworked the PR. Confirmed both paths retain one replica's payload: performFailoverSearch returns emptyResults[0].data and the all-empty fan-out branch returns successes[0].data, while servedBy correctly names them all. So the guard saw only the first replica's unresponsive_engines, which produced both problems you describe — a false throw when a later replica completed cleanly, and a partial engine list when replicas failed differently.
What changed in a785a7c:
MultiInstanceSearchResultgains an optionalemptyResponses: SearXNGWeb[]. Both helpers now populate it in their all-empty branches with every zero-row response, not just the retained one.servedBysemantics and the with-results return paths are untouched.- A single
classifyEmptyResponses(responses)does the classification, so the rule lives in one place rather than being duplicated in each helper. It returnsnullas soon as any zero-row response has no unresponsive engines — one clean empty replica is authoritative evidence that nothing matched — and otherwise returns the union of failures. - The union is keyed by engine name in a
Mapand kept in first-seen order, so an engine failing on several replicas is named once with a deterministic reason. Nothing depends on incidental Set/Map iteration beyond documented insertion order. performWebSearchclassifies once:classifyEmptyResponses(emptyResponses ?? [data]). The single-instance path leavesemptyResponsesundefined and so classifies[data], exactly as before.
Five tests added for the multi-instance cases, which nothing previously exercised: failover and fan-out each get "earlier replica failing + later replica clean → does not throw" and "every empty replica failing → throws naming the union", plus a dedup test asserting an engine failing on two replicas appears exactly once with its first-seen reason.
I checked the new tests actually catch the bug rather than merely passing. Reverting the classifier to the single retained payload (classifyEmptyResponses([data])) fails exactly the four multi-instance tests and leaves the single-instance ones green, which also confirms that path is unchanged. Removing the dedup keying fails only the dedup test, with brave (timeout), startpage (Suspended: CAPTCHA), brave (too many requests).
Build, lint, npm test (786/786) and npm run test:coverage all pass; search.ts coverage went from 98.26/95.37 to 98.79/95.54.
The first guard read `data.unresponsive_engines`, but both all-empty multi-instance paths retain a single replica's payload chosen by ordering: `performFailoverSearch` keeps `emptyResults[0].data` and the all-empty fan-out branch keeps `successes[0].data`. That made the verdict depend on replica order — a first replica with failing engines threw even when a later replica completed cleanly with no matches, and when replicas reported different failures only the first replica's engines were named. Carry every zero-row response out of both helpers as `emptyResponses` and classify once in `performWebSearch` via `classifyEmptyResponses`, so the two helpers do not duplicate the rule. The search counts as failed only when no zero-row response came back clean; a single clean empty replica is authoritative evidence that nothing matched. When it does fail, engines are the deduplicated union across replicas, keyed by name in first-seen order so the message is stable and an engine failing on several replicas is named once. `servedBy` semantics are unchanged, and the single-instance path still classifies `[data]` exactly as before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBoX4ym1XSuM4cHHdZ4ufd
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a785a7ca63
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!failedEngines.has(entry[0])) { | ||
| failedEngines.set(entry[0], entry); |
There was a problem hiding this comment.
Validate engine entries before indexing them
When a zero-result JSON response contains a malformed entry such as unresponsive_engines: [null], hasItems accepts the array and this indexing throws a raw TypeError before the bounded engine-failure error can be created. Parsed responses are only cast to SearXNGWeb, so this external data is not runtime-validated; skip or normalize non-array entries before reading their fields.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid, and worse than described — fixed in 82f2b1f. I reproduced it before changing anything:
null entry -> THROW TypeError: Cannot read properties of null (reading '0')
bare string entry -> OK "b (r)"
tuple missing reason -> OK "yahoo"
empty tuple -> OK "unknown engine"
number entry -> OK "unknown engine"
well-formed -> OK "brave (timeout)"
Two corrections to the report, both making it more serious rather than less:
describeEngineFailurein error-handler.ts was not already hardened against this. It readasEngineFailureText(entry[0]), which throws onnulljust as the classifier did — so both layers were vulnerable, not just the new one. My existing malformed-entry test passed[undefined, undefined], a well-formed two-element tuple, so it never exercised a malformed entry.- A bare string entry did not throw but rendered wrongly: indexing
"brave"positionally yieldsentry[0] === "b",entry[1] === "r", so the message saidb (r).
Fix, following your point about not letting two layers hold different notions of "malformed": one exported normalizeEngineFailure(entry: unknown): [string, string] in error-handler.ts, used by both the classifier and the renderer. It coerces a non-array entry to [entry, undefined] so a bare string reads as the engine name, runs both halves through the existing single-line/120-char bounding, and falls back to unknown engine. The dedup key is therefore always a string.
On the semantics you raised, I chose normalize, not skip. Skipping would mean a replica whose entries are all malformed yields size === 0 and returns null, i.e. "clean empty → No results found" — but hasItems has already established that this replica did report failures. Reporting "no results" for a replica that explicitly reported failing engines is precisely the silent failure this PR exists to remove, so it would be self-defeating. Normalizing keeps the error firing and degrades the message to unknown engine; dedup then collapses several unidentifiable failures under that one key, which reads correctly. Both the docstring on classifyEmptyResponses and the commit message record the reasoning.
Tests: four added. In error-handler, a mixed array of null, ['yahoo'], [], 42 and a well-formed tuple, plus one pinning the bare-string case to brave rather than b (r). In search, an end-to-end performWebSearch with [null, 'brave', ['yahoo'], ['startpage', 'Suspended: CAPTCHA']] asserting error.name === 'MCPSearXNGError' and that the well-formed entry is still named, plus the all-malformed case asserting it still raises with exactly one unknown engine.
Mutation-verified again. Reverting the classifier to raw indexing fails both new search tests with raw TypeError escaped: Cannot read properties of null (reading '0'); reverting the renderer fails both new error-handler tests, the second reproducing b (r) exactly.
Build, lint, npm test (790/790) and npm run test:coverage all pass. error-handler.ts coverage 98.34/93.18 → 98.43/93.40.
`hasItems` proves only that the array is non-empty, so indexing `entry[0]` in `classifyEmptyResponses` threw a raw `TypeError` on a response carrying `unresponsive_engines: [null]`, before the bounded engine-failure error could be built. Responses are cast to `SearXNGWeb` and never runtime validated, so any entry shape can arrive. `describeEngineFailure` was vulnerable to the same input, and additionally mis-rendered a bare string entry: indexing "brave" positionally produced "b (r)". The earlier malformed-entry test passed `[undefined, undefined]`, a well-formed two-element tuple, so it never covered a malformed entry. Add a single exported `normalizeEngineFailure` in error-handler.ts and route both the classifier and the renderer through it, so the two layers share one definition of "malformed" rather than each carrying their own. It coerces an entry to `[engine, reason]`, treats a bare string as the engine name, and falls back to "unknown engine", so the dedup key is always a string. A replica whose failures are all unidentifiable still counts as failed and collapses to one "unknown engine" entry, rather than being reclassified as a clean empty: `hasItems` has already established that the replica reported failures, and returning "no results" there would resurrect exactly the silent failure this branch exists to remove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBoX4ym1XSuM4cHHdZ4ufd
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 82f2b1f8b3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (data.results.length === 0) { | ||
| const failedEngines = classifyEmptyResponses(emptyResponses ?? [data]); |
There was a problem hiding this comment.
Preserve direct answers before raising engine failure
When SearXNG returns a usable answers or infoboxes entry with no result rows while an unrelated engine is unresponsive, this guard throws before formatSearchMetadata() can return that content. For example, an answerer plugin can supply a calculator answer even if a web engine times out; the search demonstrably ran and produced useful output, but this change turns it into a failed tool call. Restrict the engine-failure classification to responses that contain neither rows nor usable answer/infobox metadata.
Useful? React with 👍 / 👎.
Fixes #262.
Problem
unresponsive_enginesis declared onSearXNGWeb(src/types.ts:31) but never read anywhere insrc/— it is parsed off the SearXNG response and discarded.So a search where every engine that reported back failed is returned to the MCP client as a successful tool call carrying the same string as a search that ran cleanly and matched nothing:
Verified against a live SearXNG instance with v2.1.0, pinning a single engine so the all-engines-failed case is deterministic:
engines=resultsunresponsive_enginesstartpage[][["startpage", "Suspended: CAPTCHA"]]brave[][["brave", "too many requests"]]yahoo[][["yahoo", "HTTP protocol error"]]wikidata[][]— the only genuinely empty oneThrough
searxng_web_search,engines=startpageandengines=wikidatareturn byte-identical success strings. An autonomous caller reads the CAPTCHA as an authoritative "nothing exists" and stops looking, rather than retrying or switching engines. The existing message's own hint — "check if SearXNG search engines are working" — is precisely the check the server can already perform from the response body and does not.Change
createEngineFailureErrorinsrc/error-handler.ts, raised fromperformWebSearchwhen the raw result set is empty and no zero-row response came back clean.The classification runs on the aggregate, not on one payload. Both all-empty multi-instance paths retain a single replica's
datachosen by ordering —performFailoverSearchkeepsemptyResults[0].data, the all-empty fan-out branch keepssuccesses[0].data— so readingdata.unresponsive_enginesalone would make the verdict depend on replica order. Instead:emptyResponsesonMultiInstanceSearchResult.servedBysemantics and the with-results return paths are unchanged.classifyEmptyResponses()decides the verdict, so the two helpers do not duplicate the rule. It returnsnullas soon as any zero-row response reports no failing engines — a clean empty replica is authoritative evidence that nothing matched, whichever replica produced it — and otherwise returns the union of failures across replicas, keyed by engine name in first-seen order so an engine failing on several replicas is named once with a deterministic reason.performWebSearchclassifies once, viaclassifyEmptyResponses(emptyResponses ?? [data]). The single-instance path leavesemptyResponsesundefined and classifies[data], so its behaviour is exactly as it was.Two further details:
data.results, not the filtered/sliced set. If engines did return rows andmin_score/num_resultsremoved them, that is a filtering outcome and the existing no-results message stays correct.response_format === "json"branch, so both response formats are covered. (compactJSON drops everything butresults, sounresponsive_enginesdid not survive there either.)The error names each failed engine and its reason so the caller can act.
unresponsive_enginesis external data that is only cast toSearXNGWeband never runtime-validated, so a single exportednormalizeEngineFailure()coerces each entry to[engine, reason]: a non-array entry (null, a bare string, a number) and a short tuple are all handled, values are bounded to a single line of at most 120 characters, and an unidentifiable engine falls back tounknown engine. The classifier and the message renderer both go through it, so there is one definition of "malformed" in this path and the dedup key is always a string. A replica whose failures are all unidentifiable still counts as failed and collapses to oneunknown engineentry —hasItemshas already established that it reported failures, and calling it a clean empty would resurrect the very silent failure this PR removes.This follows the same fail-closed shape the repo already uses for
engines+time_range(#244) and for the HTML-fallback path, which likewise avoids "silently returning empty results".Files changed
src/error-handler.ts—createEngineFailureError, the exportednormalizeEngineFailureshared with the classifier, and their formatting helperssrc/search.ts—classifyEmptyResponses, theemptyResponsesfield and its two producers, and the guard inperformWebSearchabove thejsonbranch__tests__/unit/error-handler.test.ts— 5 tests: message content, empty/absent tuple halves, the length cap, entries that are not tuples at all (null,['yahoo'],[],42), and a bare string entry reading as the engine name__tests__/unit/search.test.ts— 10 tests. Single instance: all-engines-failed raises (text), the same forjson, genuinely-empty keeps the existing message, failing engines with rows returned still produce results, rows filtered to empty bymin_scorekeep the existing message. Multi-instance: failover and fan-out each get "earlier replica failing + later replica clean → does not throw" and "every empty replica failing → throws naming the union", plus a dedup test asserting an engine failing on two replicas is named exactly once. Untrusted input: a response mixing malformed and well-formed entries must raiseMCPSearXNGErrorrather than a rawTypeErrorand still name the well-formed engine, and an all-malformed response must still raise with exactly oneunknown engine.README.md— one bullet in thesearxng_web_searchsection, alongside the existingtime_rangefail-closed noteThe pre-existing test "text output prepends infoboxes but omits unresponsive engines" already covers the failing-engines-with-rows case and is unchanged.
Verification
Baseline on
mainbefore any edit: build 0, lint 0, 773/773 tests, 96.1% lines / 92.11% branches. After the change:I also confirmed the tests are not vacuous, by mutation rather than by assertion alone:
classifyEmptyResponses([data])) fails exactly the four multi-instance tests, and leaves the single-instance ones green — which also demonstrates that path is untouched.src/search.tsentirely fails exactly the two single-instance "raises" tests withMissing expected rejection.brave (timeout), startpage (Suspended: CAPTCHA), brave (too many requests).Cannot read properties of null (reading '0')and, for the renderer,b (r)in place ofbrave.Trade-off worth your call
SearXNG's response lists engines that failed but not engines that answered successfully, so within a single instance a mixed outcome — some engines returned zero rows while one failed — is indistinguishable from a total failure and raises under this rule. (Across replicas this is now resolved: one clean replica settles it.) The cost of that false positive is a retry; the cost of today's behaviour is an agent silently accepting a failed search as fact, so I have erred toward surfacing. Happy to change it if you would rather be more conservative — either gate it behind an env flag, or narrow it to the case where the caller explicitly pinned
engines=, where the denominator is known. Say which you prefer and I will push the change.🤖 Generated with Claude Code
https://claude.ai/code/session_01NBoX4ym1XSuM4cHHdZ4ufd