diff --git a/client/modules/search.test.ts b/client/modules/search.test.ts index 116c5234..173dfdec 100644 --- a/client/modules/search.test.ts +++ b/client/modules/search.test.ts @@ -317,15 +317,15 @@ describe("Search Module", () => { }); describe("Cache Failure Scenarios", () => { - it("should return empty results instead of throwing when a text search fails end-to-end", async () => { + it("should rethrow when a text search fails end-to-end", async () => { // getCachedResult/cacheResult swallow their own read/write errors, so a - // cache failure surfaces as a miss followed by a real fetch — searchText - // must still resolve gracefully instead of throwing. + // cache failure surfaces as a miss followed by a real fetch — the fetch + // failure must propagate so the caller can mark the search as failed. mockFetch.mockRejectedValue(new Error("Cache read error")); - const results = await searchModule.searchText("test query"); - - expect(results).toEqual([]); + await expect(searchModule.searchText("test query")).rejects.toThrow( + "Cache read error", + ); expect(addLogEntry).toHaveBeenCalledWith( expect.stringContaining("Text search failed"), ); @@ -344,15 +344,15 @@ describe("Search Module", () => { }); describe("Database Integrity Failures", () => { - it("should return empty results instead of throwing when an image search fails end-to-end", async () => { + it("should rethrow when an image search fails end-to-end", async () => { // ensureIntegrity/cleanExpiredCache swallow their own errors, so a - // corrupted database surfaces as a miss followed by a real fetch — - // searchImages must still resolve gracefully instead of throwing. + // corrupted database surfaces as a miss followed by a real fetch — the + // fetch failure must propagate so the caller can mark the search failed. mockFetch.mockRejectedValue(new Error("Database integrity check failed")); - const results = await searchModule.searchImages("test query"); - - expect(results).toEqual([]); + await expect(searchModule.searchImages("test query")).rejects.toThrow( + "Database integrity check failed", + ); expect(addLogEntry).toHaveBeenCalledWith( expect.stringContaining("Image search failed"), ); diff --git a/client/modules/search.ts b/client/modules/search.ts index b609f486..bc43ce98 100644 --- a/client/modules/search.ts +++ b/client/modules/search.ts @@ -494,10 +494,12 @@ const searchService = { }, ); } catch (error) { + // Rethrow so the caller can tell a failed search apart from one that + // genuinely has no results; an empty array means zero results only. addLogEntry( `Text search failed: ${error instanceof Error ? error.message : String(error)}`, ); - return []; + throw error; } }, @@ -522,10 +524,12 @@ const searchService = { }, ); } catch (error) { + // Rethrow so the caller can tell a failed search apart from one that + // genuinely has no results; an empty array means zero results only. addLogEntry( `Image search failed: ${error instanceof Error ? error.message : String(error)}`, ); - return []; + throw error; } }, diff --git a/client/modules/textGeneration.degradation.test.ts b/client/modules/textGeneration.degradation.test.ts index f2f68a8a..da59f60b 100644 --- a/client/modules/textGeneration.degradation.test.ts +++ b/client/modules/textGeneration.degradation.test.ts @@ -7,6 +7,7 @@ const harness = vi.hoisted(() => { textGenerationState: "idle", textSearchState: "idle", textSearchResults: [] as unknown[], + llmTextSearchResults: [] as unknown[], pageContents: {} as Record, searchRunId: "run-1", searchPromise: Promise.resolve({}) as Promise, @@ -41,7 +42,9 @@ vi.mock("./pubSub", () => ({ updateConversationSummary: vi.fn(), updateImageSearchResults: vi.fn(), updateImageSearchState: vi.fn(), - updateLlmTextSearchResults: vi.fn(), + updateLlmTextSearchResults: (results: unknown[]) => { + harness.state.llmTextSearchResults = results; + }, updatePageContents: (contents: Record) => { harness.state.pageContents = contents; }, @@ -150,6 +153,7 @@ describe("search degradation", () => { harness.state.textGenerationState = "idle"; harness.state.textSearchState = "idle"; harness.state.textSearchResults = []; + harness.state.llmTextSearchResults = []; harness.state.settings.enableAiResponse = false; harness.state.settings.enableTextSearch = true; harness.stateTransitions.length = 0; @@ -179,7 +183,7 @@ describe("search degradation", () => { expect(harness.state.textSearchState).toBe("completed"); }); - it("reports the text search as failed when the keyword fallback is also empty", async () => { + it("keeps the text search completed when the keyword fallback is also empty", async () => { vi.mocked(searchText).mockResolvedValue([]); await searchAndRespond(); @@ -187,6 +191,29 @@ describe("search degradation", () => { expect(searchText).toHaveBeenCalledTimes(2); expect(harness.state.textSearchResults).toEqual([]); + expect(harness.state.textSearchState).toBe("completed"); + }); + + it("marks the text search as failed when the search request errors", async () => { + vi.mocked(searchText).mockRejectedValue( + new Error("HTTP error! status: 502"), + ); + + // A previous search left the LLM grounding channel populated; the failed + // search must clear it so the AI can't ground on stale results. + harness.state.llmTextSearchResults = [ + ["Stale result", "Old snippet", "https://stale.example.com"], + ]; + + await searchAndRespond(); + await harness.state.searchPromise; + + // An outage is not an empty result set: the keyword fallback must not + // fire, the search ends failed so the retry UI shows up, and the stale + // grounding is dropped. + expect(searchText).toHaveBeenCalledTimes(1); + expect(harness.state.textSearchResults).toEqual([]); + expect(harness.state.llmTextSearchResults).toEqual([]); expect(harness.state.textSearchState).toBe("failed"); }); }); diff --git a/client/modules/textGeneration.ts b/client/modules/textGeneration.ts index d7e1f60d..68bcbb2a 100644 --- a/client/modules/textGeneration.ts +++ b/client/modules/textGeneration.ts @@ -232,6 +232,8 @@ export async function searchAndRespond() { updateTextSearchResults([]); + updateLlmTextSearchResults([]); + updateImageSearchResults([]); updateChatMessages([]); @@ -425,42 +427,46 @@ async function startTextSearch(query: string) { if (getSettings().enableTextSearch) { updateTextSearchState("running"); - let textResults = await searchText( - searchQuery, - getSettings().searchResultsLimit, - ); - - if (textResults.length === 0) { - const queryKeywords = await getKeywords(query, 10); - const keywordResults = await searchText( - queryKeywords.join(" "), + try { + let textResults = await searchText( + searchQuery, getSettings().searchResultsLimit, ); - textResults = keywordResults; - } - results.textResults = textResults; + if (textResults.length === 0) { + const queryKeywords = await getKeywords(query, 10); + const keywordResults = await searchText( + queryKeywords.join(" "), + getSettings().searchResultsLimit, + ); + textResults = keywordResults; + } + + results.textResults = textResults; - updateTextSearchState( - results.textResults.length === 0 ? "failed" : "completed", - ); - updateTextSearchResults(textResults); + updateTextSearchState("completed"); + updateTextSearchResults(textResults); - const resultsForLlm = textResults.slice(0, searchResultsToConsider); - updateLlmTextSearchResults(resultsForLlm); + const resultsForLlm = textResults.slice(0, searchResultsToConsider); + updateLlmTextSearchResults(resultsForLlm); - updateSearchResults(getCurrentSearchRunId(), { - type: "text", - items: textResults.map(([title, snippet, url]) => ({ - title, - url, - snippet, - })), - }); + updateSearchResults(getCurrentSearchRunId(), { + type: "text", + items: textResults.map(([title, snippet, url]) => ({ + title, + url, + snippet, + })), + }); - // Started here but awaited last: only the AI answer waits on it, while - // image search and the history write carry on. - pageContentsRead = readPageContents(searchQuery, resultsForLlm); + // Started here but awaited last: only the AI answer waits on it, while + // image search and the history write carry on. + pageContentsRead = readPageContents(searchQuery, resultsForLlm); + } catch { + // An outage (non-200 from /search/*) is not the same as a query with no + // results; only the former leaves the search in the failed state. + updateTextSearchState("failed"); + } } if (getSettings().enableImageSearch) { @@ -476,25 +482,29 @@ async function startImageSearch( searchQuery: string, results: { textResults: TextSearchResults; imageResults: ImageSearchResults }, ) { - const imageResults = await searchImages( - searchQuery, - getSettings().searchResultsLimit, - ); - results.imageResults = imageResults; - updateImageSearchState( - results.imageResults.length === 0 ? "failed" : "completed", - ); - updateImageSearchResults(imageResults); - - updateSearchResults(getCurrentSearchRunId(), { - type: "image", - items: imageResults.map(([title, url, thumbnailUrl, sourceUrl]) => ({ - title, - url, - thumbnail: thumbnailUrl, - sourceUrl, - })), - }); + try { + const imageResults = await searchImages( + searchQuery, + getSettings().searchResultsLimit, + ); + results.imageResults = imageResults; + updateImageSearchState("completed"); + updateImageSearchResults(imageResults); + + updateSearchResults(getCurrentSearchRunId(), { + type: "image", + items: imageResults.map(([title, url, thumbnailUrl, sourceUrl]) => ({ + title, + url, + thumbnail: thumbnailUrl, + sourceUrl, + })), + }); + } catch { + // Same distinction as the text path: an outage fails the search, an empty + // result set does not. + updateImageSearchState("failed"); + } } function canDownloadModels(): Promise { diff --git a/docs/configuration.md b/docs/configuration.md index 9a0615e7..6984d3b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -291,7 +291,7 @@ INTERNAL_OPENAI_COMPATIBLE_API_MODEL="llama-3.1-70b" MiniSearch logs internal events to an in-app log panel (see the Logs section of the menu), backed by `logEntriesPubSub` in `client/modules/logEntries.ts`. There is no separate browser-console debug flag to enable. -**Diagnosing empty search results:** if a search returns no results, the client only sees a generic failure. The actual reason is printed to the server's console (via `server/webSearchService.ts`, always-on `debug` logging), either the SearXNG engines that failed (timeouts, suspensions, rate limits, from SearXNG's `unresponsive_engines` field) or a note that all returned results were discarded during processing (missing title, snippet, or media source). Check the server logs (`docker compose logs`) when troubleshooting failed searches. +**Diagnosing empty search results:** the client distinguishes a search that returned no results (a no-results alert, state stays `completed`) from one that failed outright on an outage (a distinct failed state with a retry). The actual reason is printed to the server's console (via `server/webSearchService.ts`, always-on `debug` logging), either the SearXNG engines that failed (timeouts, suspensions, rate limits, from SearXNG's `unresponsive_engines` field) or a note that all returned results were discarded during processing (missing title, snippet, or media source). Check the server logs (`docker compose logs`) when troubleshooting failed searches. Check effective configuration: ```typescript diff --git a/docs/failure-injection.md b/docs/failure-injection.md index 53cd6a4c..a8876967 100644 --- a/docs/failure-injection.md +++ b/docs/failure-injection.md @@ -9,14 +9,15 @@ needed. ## Degradation Matrix | Failure injected | Expected degradation | Where it is pinned | -|---|---|---| +| --- | --- | --- | | SearXNG answers 500 once, then recovers | Retried with exponential backoff, results returned | `server/webSearchService.test.ts` › retry logic | | SearXNG answers 500 on every attempt | Retry cycle exhausts (4 requests) and costs a single circuit-breaker failure | `server/webSearchService.test.ts` › graceful degradation | | SearXNG fails five cycles in a row | Circuit opens; further searches short-circuit without calling the upstream | `server/webSearchService.test.ts` › graceful degradation | | SearXNG recovers after the reset timeout | One healthy response closes the circuit again | `server/webSearchService.test.ts` › graceful degradation | -| SearXNG answers 200 with a non-JSON body | Empty result set, no throw | `server/webSearchService.test.ts` › graceful degradation | +| SearXNG answers 200 with a non-JSON body | `fetchSearXNG` throws and the endpoint answers HTTP 502 | `server/webSearchService.test.ts` › graceful degradation | | SearXNG returns results that are all unusable | Empty result set, no throw | `server/webSearchService.test.ts` › graceful degradation | -| Provider down vs. genuinely zero results | Both yield `[]` (indistinguishable downstream, see Known Limitations) | `server/webSearchService.test.ts` › graceful degradation | +| Provider down vs. genuinely zero results | Provider down: `fetchSearXNG` throws, the endpoint answers HTTP 502, and the client search state becomes `failed`; genuinely zero results: HTTP 200 with `[]`, state stays `completed` and the no-results alert renders | `server/webSearchService.test.ts` and `server/searchEndpointServerHook.test.ts` › graceful degradation | +| SearXNG is down | `/search/text` and `/search/images` answer HTTP 502 with a JSON error | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Reranker is not ready | Results served in SearXNG order, HTTP 200 | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Reranking throws mid-request | Results served in SearXNG order, HTTP 200 | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Reranker is not ready on an image search | Images still served with thumbnails | `server/searchEndpointServerHook.test.ts` › graceful degradation | @@ -25,7 +26,9 @@ needed. | Thumbnail host never answers | Request aborted after the timeout, image dropped | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Search returns nothing to the endpoint | HTTP 200 with `[]`, not an error | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Text search returns nothing to the client | Keyword-only query retried as a fallback | `client/modules/textGeneration.degradation.test.ts` | -| Keyword fallback also returns nothing | Text search state becomes `failed` | `client/modules/textGeneration.degradation.test.ts` | +| Keyword fallback also returns nothing | Text search state stays `completed` with the no-results alert | `client/modules/textGeneration.degradation.test.ts` | +| SearXNG is down on the client | Text search state becomes `failed`, keyword fallback does not fire | `client/modules/textGeneration.degradation.test.ts` | +| Client search fails after a previous search populated the LLM channel | The grounding channel is cleared, so the AI can't ground on the previous query's results | `client/modules/textGeneration.degradation.test.ts` | | Result page host resolves into a private range | Page skipped, no request is made | `server/pageContentService.test.ts` › fetchPageContents | | Result page redirects into a private range | Redirect not followed, page skipped | `server/pageContentService.test.ts` › fetchPageContents | | Result page errors, is not a document, or yields no text | That page is skipped, the others still return | `server/pageContentService.test.ts` › fetchPageContents | @@ -41,13 +44,6 @@ needed. | `/inference` answers 503 | Generation state becomes `failed`, nothing persisted | `client/modules/textGeneration.degradation.test.ts` | | Generation interrupted mid-stream | Partial answer preserved, state stays `interrupted` | `client/modules/textGeneration.degradation.test.ts` | -## Known Limitations - -`fetchSearXNG` catches every failure and returns `[]`, so "the provider is down" -and "there are no results for this query" reach the client as the same response. -The matrix pins that behavior rather than hiding it: changing it means changing -the endpoint contract, and the test is where that decision becomes visible. - ## Adding a Row Keep each case next to the module it covers, reusing that file's mocks and diff --git a/server/searchEndpointServerHook.test.ts b/server/searchEndpointServerHook.test.ts index 9f30f53a..d1807db6 100644 --- a/server/searchEndpointServerHook.test.ts +++ b/server/searchEndpointServerHook.test.ts @@ -443,8 +443,10 @@ describe("searchEndpointServerHook", () => { } }); - it("serves an empty result set instead of an error when SearXNG is down", async () => { - vi.mocked(fetchSearXNG).mockResolvedValue([]); + it("answers 502 when SearXNG is down", async () => { + vi.mocked(fetchSearXNG).mockRejectedValue( + new Error("SearXNG request failed with status 503"), + ); const handler = getRegisteredHandler(); const response = createResponse(); @@ -455,8 +457,28 @@ describe("searchEndpointServerHook", () => { vi.fn(), ); - expect(response.statusCode).toBe(200); - expect(response.end).toHaveBeenCalledWith("[]"); + expect(response.statusCode).toBe(502); + expect(response.end).toHaveBeenCalledWith( + JSON.stringify({ error: "Search service unavailable" }), + ); + }); + + it("answers 502 on image searches when SearXNG is down", async () => { + vi.mocked(fetchSearXNG).mockRejectedValue(new Error("network down")); + + const handler = getRegisteredHandler(); + const response = createResponse(); + + await handler( + createRequest("/search/images?q=cats&token=abc"), + response, + vi.fn(), + ); + + expect(response.statusCode).toBe(502); + expect(response.end).toHaveBeenCalledWith( + JSON.stringify({ error: "Search service unavailable" }), + ); }); }); diff --git a/server/searchEndpointServerHook.ts b/server/searchEndpointServerHook.ts index fca4b664..da6337c7 100644 --- a/server/searchEndpointServerHook.ts +++ b/server/searchEndpointServerHook.ts @@ -129,7 +129,17 @@ export function searchEndpointServerHook< const isTextSearch = request.url?.startsWith("/search/text"); const searchType = isTextSearch ? "text" : "images"; - const searxngResults = await fetchSearXNG(query, searchType, limit); + let searxngResults: TextResult[] | ImageResult[]; + try { + searxngResults = await fetchSearXNG(query, searchType, limit); + } catch { + // SearXNG is unreachable: answer non-200 so the client can tell an + // outage apart from a search that genuinely has no results. + response.statusCode = 502; + response.setHeader("Content-Type", "application/json"); + response.end(JSON.stringify({ error: "Search service unavailable" })); + return; + } if (isTextSearch) { const results = searxngResults as TextResult[]; diff --git a/server/webSearchService.test.ts b/server/webSearchService.test.ts index 7e4675c2..4c317a0d 100644 --- a/server/webSearchService.test.ts +++ b/server/webSearchService.test.ts @@ -121,13 +121,13 @@ describe("WebSearchService", () => { } }); - it("should return empty array on fetchSearXNG error", async () => { + it("should throw when SearXNG is unreachable", async () => { (global.fetch as MockedFunction).mockRejectedValue( new Error("Network failure"), ); - const results = await fetchSearXNG("test query", "text"); - expect(Array.isArray(results)).toBe(true); - expect(results).toHaveLength(0); + await expect(fetchSearXNG("test query", "text")).rejects.toThrow( + "Network failure", + ); }); }); @@ -179,15 +179,17 @@ describe("retry logic", () => { expect(results).toHaveLength(1); }); - it("returns empty array when all retries return 500", async () => { + it("throws when all retries return 500", async () => { fetchMock.mockResolvedValue(createMockResponse("", false, 500)); const promise = fetchSearXNG("test", "text"); + const outcome = expect(promise).rejects.toThrow( + "SearXNG request failed with status 500", + ); await vi.runAllTimersAsync(); - const results = await promise; + await outcome; expect(fetchMock).toHaveBeenCalledTimes(4); - expect(results).toHaveLength(0); }); }); @@ -207,11 +209,15 @@ describe("graceful degradation", () => { * Advances only far enough to drain the retry backoff. `runAllTimersAsync` * would also fire the breaker's own reset timer, flipping an open circuit to * half-open and letting the next call reach SearXNG again. + * + * The rejection assertion is attached before the timers advance, so the + * failure stays a handled rejection the whole way through. */ async function searchThroughRetries(breaker: CircuitBreaker) { const promise = fetchSearXNG("failure injection", "text", 30, breaker); + const outcome = expect(promise).rejects.toThrow(); await vi.advanceTimersByTimeAsync(RETRY_BACKOFF_TOTAL_MS); - return promise; + await outcome; } beforeEach(() => { @@ -227,8 +233,7 @@ describe("graceful degradation", () => { fetchMock.mockResolvedValue(createMockResponse("", false, 500)); for (let cycle = 0; cycle < breakerOptions.failureThreshold - 1; cycle++) { - const results = await searchThroughRetries(breaker); - expect(results).toEqual([]); + await searchThroughRetries(breaker); } // Four full cycles of upstream requests, still one failure short of opening. @@ -252,9 +257,8 @@ describe("graceful degradation", () => { expect(breaker.getState("searxng")).toBe("OPEN"); const callsWhileOpen = fetchMock.mock.calls.length; - const results = await searchThroughRetries(breaker); + await searchThroughRetries(breaker); - expect(results).toEqual([]); expect(fetchMock).toHaveBeenCalledTimes(callsWhileOpen); }); @@ -267,7 +271,9 @@ describe("graceful degradation", () => { failure < breakerOptions.failureThreshold; failure++ ) { - await fetchSearXNG("failure injection", "text", 30, breaker); + await expect( + fetchSearXNG("failure injection", "text", 30, breaker), + ).rejects.toThrow(); } expect(breaker.getState("searxng")).toBe("OPEN"); @@ -286,10 +292,10 @@ describe("graceful degradation", () => { expect(breaker.getState("searxng")).toBe("CLOSED"); }); - it("cannot be told apart downstream: provider down and genuinely empty both yield []", async () => { + it("throws when the provider is down and returns an empty array for zero results", async () => { const downBreaker = new CircuitBreaker({ failureThreshold: 1 }); fetchMock.mockResolvedValue(createMockResponse("", false, 503)); - const providerDown = await fetchSearXNG( + const providerDown = fetchSearXNG( "failure injection", "text", 30, @@ -307,24 +313,25 @@ describe("graceful degradation", () => { emptyBreaker, ); - // The breaker knows which one was a failure; the return value does not. + // The breaker still records which one was a failure, and now the caller + // can tell them apart from the outcome as well. expect(downBreaker.getState("searxng")).toBe("OPEN"); expect(emptyBreaker.getState("searxng")).toBe("CLOSED"); - expect(providerDown).toEqual([]); + await expect(providerDown).rejects.toThrow(); expect(noResults).toEqual([]); }); - it("returns an empty array when SearXNG answers 200 with a malformed body", async () => { + it("throws when SearXNG answers 200 with a malformed body", async () => { fetchMock.mockResolvedValue(createMockResponse("gateway")); - const results = await fetchSearXNG( - "failure injection", - "text", - 30, - new CircuitBreaker(breakerOptions), - ); - - expect(results).toEqual([]); + await expect( + fetchSearXNG( + "failure injection", + "text", + 30, + new CircuitBreaker(breakerOptions), + ), + ).rejects.toThrow(); }); it("returns an empty array when every result is dropped during processing", async () => { @@ -416,20 +423,25 @@ describe("query privacy", () => { } it("never writes the query to the log", async () => { - for (const response of [ - emptyResponse(), - unusableResultsResponse(), - createMockResponse("gateway"), - ]) { + for (const response of [emptyResponse(), unusableResultsResponse()]) { fetchMock.mockResolvedValue(response); await fetchSearXNG(DISTINCTIVE_QUERY, "text", 30, new CircuitBreaker()); } + // The malformed body and the network failure both make fetchSearXNG + // throw; the point here is that neither path ever logs the query. + fetchMock.mockResolvedValue(createMockResponse("gateway")); + await expect( + fetchSearXNG(DISTINCTIVE_QUERY, "text", 30, new CircuitBreaker()), + ).rejects.toThrow(); + fetchMock.mockResolvedValue(imageResultsResponse()); await fetchSearXNG(DISTINCTIVE_QUERY, "images", 30, new CircuitBreaker()); fetchMock.mockRejectedValue(new Error("Network failure")); - await fetchSearXNG(DISTINCTIVE_QUERY, "images", 30, new CircuitBreaker()); + await expect( + fetchSearXNG(DISTINCTIVE_QUERY, "images", 30, new CircuitBreaker()), + ).rejects.toThrow(); const output = logLines.join("\n"); expect(logLines.length).toBeGreaterThan(0); @@ -476,11 +488,11 @@ describe("circuit breaker", () => { fetchMock.mockResolvedValue(createMockResponse("", false, 503)); for (let i = 0; i < 5; i++) { - await fetchSearXNG("test", "text", 30, breaker); + await expect(fetchSearXNG("test", "text", 30, breaker)).rejects.toThrow(); } const callsBeforeBreak = fetchMock.mock.calls.length; - await fetchSearXNG("test", "text", 30, breaker); + await expect(fetchSearXNG("test", "text", 30, breaker)).rejects.toThrow(); expect(fetchMock.mock.calls.length).toBe(callsBeforeBreak); }); @@ -490,11 +502,11 @@ describe("circuit breaker", () => { fetchMock.mockResolvedValue(createMockResponse("", false, 503)); for (let i = 0; i < 4; i++) { - await fetchSearXNG("test", "text", 30, breaker); + await expect(fetchSearXNG("test", "text", 30, breaker)).rejects.toThrow(); } const callsBefore = fetchMock.mock.calls.length; - await fetchSearXNG("test", "text", 30, breaker); + await expect(fetchSearXNG("test", "text", 30, breaker)).rejects.toThrow(); expect(fetchMock.mock.calls.length).toBe(callsBefore + 1); }); diff --git a/server/webSearchService.ts b/server/webSearchService.ts index c7a091a0..9e73cbbb 100644 --- a/server/webSearchService.ts +++ b/server/webSearchService.ts @@ -231,9 +231,12 @@ export async function fetchSearXNG( try { return await processSearchResults(query, searchType, limit, breaker); } catch (error) { + // Upstream failure (bad status, network error, malformed body, open + // circuit) propagates so the endpoint can answer non-200; an empty array + // then means exactly one thing: zero results. const errorMessage = error instanceof Error ? error.message : String(error); printMessage(`Search failed: ${errorMessage}`); - return []; + throw error; } }