# Failure Injection & Graceful Degradation The suite exercises happy paths in the modules' own test files. This page maps the complementary set: what MiniSearch does when a dependency misbehaves. Every row is a Vitest case with the failure injected through a mocked `fetch`, a mocked module boundary, or an injected circuit breaker, so no external service is 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 answers 200 with no results and a transiently unresponsive engine (e.g. a timeout) once, then recovers | Retried with the same backoff, results returned, not counted as a failure | `server/webSearchService.test.ts` › retry logic | | SearXNG answers 200 with no results and a non-suspending engine error (e.g. server API error) on every attempt | Retry cycle exhausts (4 requests), counts one failure, and `fetchSearXNG` throws with the engine reasons rather than an empty set, so the case takes the provider-down path below | `server/webSearchService.test.ts` › retry logic, graceful degradation | | SearXNG answers 200 with no results and every unresponsive engine under a long suspension (CAPTCHA, rate limit) | Fails fast on the first attempt (1 request, no backoff), counts one failure, and throws the same error: a suspension outlasts the whole backoff by hours to a day | `server/webSearchService.test.ts` › retry logic | | 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's circuit is open while `/healthz` still answers OK | `webSearchServiceStatus` reports `unhealthy`: every search is failing, so the liveness probe alone must not report health | `server/webSearchService.test.ts` › getWebSearchServiceStatus | | A search is lost to unresponsive engines, then SearXNG answers the next one of that type | `webSearchServiceStatus` reports `degraded`, then `healthy` again; the per-engine failure counts stay | `server/webSearchService.test.ts` › getWebSearchServiceStatus | | A text search is lost to unresponsive engines and the image search the client fires next answers | Still `degraded`, and `searches.degradedSearchTypes` names the pool: the two go out to different engine pools, so answering one says nothing about the other | `server/webSearchService.test.ts` › getWebSearchServiceStatus | | The circuit reaches its reset timeout with no search since | `degraded`, not `healthy`: half-open is a timer, not a proven recovery | `server/webSearchService.test.ts` › getWebSearchServiceStatus | | 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 | 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 the thumbnail URLs as SearXNG sent them | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Reranking throws on an image search | Images still served, with the thumbnail URLs as SearXNG sent them | `server/searchEndpointServerHook.test.ts` › graceful degradation | | Reranker returns a URL absent from the result set | That image is dropped | `server/searchEndpointServerHook.test.ts` › graceful degradation | | SearXNG is down and the text-search fallback answers | The fallback's results are ranked and served as HTTP 200 on the normal text path, `searchesServedByFallback` is incremented, and no search duration is recorded | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | SearXNG is down and the fallback answers with nothing usable | HTTP 200 with `[]`, so the client shows the no-results alert instead of the unavailable one, and `searchesServedByFallback` is still incremented | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | SearXNG is down and the fallback fails too | HTTP 502 with a JSON error, `searchesFailedOnFallback` is incremented, and the log line carries the fallback's status code but never the query | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | SearXNG fails with too little of the search deadline left | The fallback is never consulted, `searchesFailedOnFallback` is incremented, and `/search/text` answers HTTP 502 rather than a result the client would never receive | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | SearXNG is down while the fallback is switched off | HTTP 502; the fallback is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | SearXNG is down on an image search while the fallback is on | HTTP 502; the fallback is text-only, so it is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | SearXNG answers while the fallback is on | HTTP 200 from SearXNG's results; the fallback is never consulted and neither fallback counter moves | `server/searchEndpointServerHook.test.ts` › graceful degradation › text search fallback | | The first fallback provider fails and the next one answers | The cascade moves on to the next provider within what is left of the shared budget, and that provider's results are served; the failure is logged with the provider name and a status code, never the query | `server/fallbackSearchService.test.ts` › the cascade | | A fallback provider answers HTTP 429 | That provider is paused until its `Retry-After` (60 s when missing or invalid, at most an hour), the next provider serves the search, and later searches skip the paused one without a request until the pause ends | `server/fallbackSearchService.test.ts` › pausing a rate-limited provider | | Every fallback provider is paused | No provider is called, the cascade throws, and `/search/text` answers HTTP 502 as for a fallback that failed | `server/fallbackSearchService.test.ts` › pausing a rate-limited provider | | Thumbnail host never answers | Request aborted after the timeout, `/thumbnail` answers HTTP 502 and the tile shows the host name | `server/thumbnailEndpointServerHook.test.ts` | | DNS lookup for a thumbnail never settles | Bounded by the same deadline as the fetch, `/thumbnail` answers HTTP 502 | `server/thumbnailEndpointServerHook.test.ts` | | Thumbnail URL resolves into a private range | Refused before any request, `/thumbnail` answers HTTP 403 | `server/thumbnailEndpointServerHook.test.ts` | | Thumbnail redirects into a private range | Redirect not followed, HTTP 403 | `server/thumbnailEndpointServerHook.test.ts` | | Thumbnail redirect carries a malformed Location | Not followed, HTTP 502, no unhandled rejection | `server/thumbnailEndpointServerHook.test.ts` | | Thumbnail upstream answers with a non-raster or an empty body (SVG included) | `/thumbnail` answers HTTP 502, the tile shows the host name | `server/thumbnailEndpointServerHook.test.ts` | | Thumbnail body exceeds the byte cap | Body truncated at the cap and served | `server/thumbnailEndpointServerHook.test.ts` | | Thumbnail upstream refuses once, then recovers | The failure is not cached; the next tile load fetches again and serves | `server/thumbnailEndpointServerHook.test.ts` | | `/thumbnail` budget is spent | HTTP 429 for the tiles only; the search budget is a separate limiter | `server/handleTokenVerification.test.ts`, `server/thumbnailEndpointServerHook.test.ts` | | A tile's `/thumbnail` request fails in the browser | That tile shows the host name, the rest of the grid is untouched | `client/components/Search/Results/Graphical/ImageResultsList.test.tsx` | | SearXNG returns an image result with no thumbnail URL | The result is dropped, so the grid never shows a host-name tile for it | `server/webSearchService.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 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; when image search is on and it answers, the channel is repopulated with the (image)-tagged titles and URLs instead | `client/modules/textGeneration.degradation.test.ts` | | Text search fails and the image search answers | The answer waits for the image results and grounds on their (image)-tagged titles and URLs, and the failed-state alert says so | `client/modules/textGeneration.degradation.test.ts` › search degradation | | Live search fails but a stale cached result exists for the same query | The cached result is served flagged stale and the UI shows the "Showing cached results" banner | `client/modules/search.test.ts` › Stale Result Fallback | | 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 | | Result host refuses three reads in a row | The host is skipped without a request for the cooling window, and the skip is counted as `skippedByBreaker` | `server/pageContentService.test.ts` › host circuit breaker | | The window passes on a skipped host | One read probes the host; a success reopens it, a refusal boxes it again | `server/pageContentService.test.ts` › host circuit breaker | | A skipped host appears twice in one batch after the window | One probe is sent, the other read is skipped | `server/pageContentService.test.ts` › host circuit breaker | | Result page body never ends | Reading stops at the byte cap | `server/pageContentService.test.ts` › fetchPageContents | | `/page-content` errors or never answers | Answer falls back to snippets, search still completes | `client/modules/pageContent.test.ts`, `client/modules/textGeneration.degradation.test.ts` › page content grounding | | `/inference` upstream is not configured | HTTP 500 with a JSON error | `server/internalApiEndpointServerHook.test.ts` › environment configuration | | `/inference` cannot list upstream models | HTTP 500 with a JSON error | `server/internalApiEndpointServerHook.test.ts` › streaming path | | Upstream model fails but another one is available | Retried on the next model, answer still streams | `server/internalApiEndpointServerHook.test.ts` › streaming path | | Every model fails before a single token is sent | HTTP 503 with a JSON error | `server/internalApiEndpointServerHook.test.ts` › streaming path | | Upstream stream ends without a finish part | Treated as a failure: retried, then `Stream ended unexpectedly` as the last error | `server/internalApiEndpointServerHook.test.ts` › streaming path | | Upstream fails after content was already sent | No retry, SSE error frame, the partial answer stands | `server/internalApiEndpointServerHook.test.ts` › streaming path | | Response is already unwritable when streaming would start | No headers set, the upstream is never called | `server/internalApiEndpointServerHook.test.ts` › streaming path | | `/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` | | The local voice catalogue cannot be fetched | The local engine is skipped and the answer is read by an OS voice | `client/modules/textToSpeech.test.ts` › falls back when the voice catalogue cannot be reached | | No local voice matches the language | Same, with a log entry naming the reason | `client/modules/textToSpeech.test.ts` › falls back when no local voice matches the language | | The synthesis worker fails before anything is audible | The answer is read by an OS voice instead | `client/modules/textToSpeech.test.ts` › falls back to the system voice when the local engine cannot load, falls back when the worker fails and no chunk was ever audible | | Every synthesized chunk fails to play (blocked autoplay) | Treated as a local-engine failure, so an OS voice reads the answer rather than the user getting silence | `client/modules/textToSpeech.test.ts` › falls back when every synthesized chunk fails to play | | The browser provides no `speechSynthesis` at all and the local engine failed | Playback resolves and returns to idle with a log entry, instead of an unhandled rejection | `client/modules/textToSpeech.test.ts` › resolves and logs when the local engine failed and there are no OS voices | | A caller streams an oversized body to `/api/validate-access-key` | Refused with `413` mid-upload and the socket dropped; the answer carries `Connection: close`, so the caller's next request opens a fresh connection instead of a dead one | `server/validateAccessKeyServerHook.socket.test.ts` › refuses an oversized body with 413 without breaking the next request | | Two follow-up question generations overlap | The older completion writes nothing, so the flag stays on until the newest finishes and the input keeps the newest question | `client/components/AiResponse/ChatInterface.test.tsx` › keeps the follow-up question flag on until the newest call finishes | | `ChatInterface` unmounts while a follow-up question is in flight | Both generation flags are cleared and the orphaned completion writes nothing into the next mount | `client/components/AiResponse/ChatInterface.test.tsx` › leaves both generation flags off after unmounting mid-generation | | A follow-up question generation rejects after a newer one started | The failed call blanks nothing, so the newer question still lands and the flag stays on until it does | `client/components/AiResponse/ChatInterface.test.tsx` › blanks nothing when an orphaned call rejects after a newer one started | | `ChatInterface` unmounts while a regenerate is still awaiting its response | The orphaned regenerate leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` › leaves the next mount's response flag alone when an orphaned regenerate settles | | `ChatInterface` unmounts while a send is still awaiting its response | The orphaned send asks for no follow-up question and leaves the next mount's response flag alone | `client/components/AiResponse/ChatInterface.test.tsx` › starts no follow-up question for a send that settles after unmount, leaves the next mount's response flag alone when an orphaned send settles | | The dictation model cannot load in the browser | While the on-device model is preferred, the local engine is abandoned for the browser's `SpeechRecognition`, and the button hides when that is missing too | `client/modules/speechToText.test.ts` › falls back to the browser recognizer when the local engine fails | | The browser recognizer fails while the on-device model is off | The session fails outright; there is no reverse fallback, so the disabled model is never downloaded behind the user's back | `client/modules/speechToText.test.ts` › does not fall back to the wasm engine when the recognizer fails | | Microphone permission is denied | A notification says so instead of the press failing quietly, and no fallback asks again | `client/components/DictationButton.test.tsx` › shows a notification when microphone permission is denied, `client/modules/speechToText.test.ts` › reports a denied microphone instead of falling back | | The dictation engine fails while the microphone permission prompt is open | The load fails rather than resolving into a dead transcriber, so the browser recognizer takes over | `client/modules/speechToText.test.ts` › fails the load when the engine dies while permission is pending, fails the load when the worker dies uncaught while permission is pending | | The browser recognizer is denied after the session started | Reported through the error callback, since the promise it would have rejected has already resolved | `client/modules/speechToText.test.ts` › reports a denial that arrives after the session resolved | | The browser recognizer ends on its own after silence | The session ends and the button leaves its recording state, with no notification, because nothing failed | `client/modules/speechToText.test.ts` › ends the session quietly when the recognizer stops on its own, `client/components/DictationButton.test.tsx` › leaves the recording state quietly when the engine ends on its own | | The dictation engine fails after it loaded | A notification says so and the session stops, instead of the UI listening forever with the microphone open | `client/modules/speechToText.test.ts` › reports a worker failure that happens after the engine loaded, `client/components/DictationButton.test.tsx` › notifies and stops when the engine fails after it loaded | | The search form unmounts while dictation is recording | The microphone track, the AudioContext and the worker are all released | `client/components/DictationButton.test.tsx` › releases the microphone when the button unmounts mid-recording | | A chat response starts generating while dictation is recording in the follow-up box | The session is stopped instead of splicing its transcript into a field that has gone read-only and is about to be cleared | `client/components/AiResponse/ChatInputArea.test.tsx` › stops a running session when a response starts generating | | An upstream dictation model file is unavailable | `/dictation-models/` answers a 502 rather than caching a partial file | `server/dictationModelServerHook.test.ts` › returns a 502 when the upstream download fails | ## Adding a Row Keep each case next to the module it covers, reusing that file's mocks and harness: a `describe("graceful degradation")` block where the file has no failure-shaped block yet, the existing one where it does (the `/inference` rows live under `environment configuration` and `streaming path`), a sibling `*.degradation.test.ts` when the case needs a harness of its own (the client rows drive `searchAndRespond` through a fake pubSub store), or a sibling `*.socket.test.ts` when only a real connection can show the failure (the `/api/validate-access-key` body cap drops the socket mid-upload, and a mocked `req`/`res` has no pooled connection to lose). A row earns its place when it fails for the right reason: mutate the degradation branch in the module (delete the `catch`, the fallback, the interrupt check, or the response header the case depends on) and confirm the case goes red before committing it. ## Related Topics - **Development Commands**: `docs/development-commands.md` - Running the suite - **Reranking**: `docs/reranking.md` - Reranker lifecycle and readiness - **Page Content**: `docs/page-content.md` - Reading result pages and its failure behavior - **AI Integration**: `docs/ai-integration.md` - Inference backends - **Overview**: `docs/overview.md` - Search and inference data flow