diff --git a/litellm/llms/searxng/search/transformation.py b/litellm/llms/searxng/search/transformation.py index 4afb8b32e4b..e61f8867605 100644 --- a/litellm/llms/searxng/search/transformation.py +++ b/litellm/llms/searxng/search/transformation.py @@ -204,6 +204,17 @@ class SearXNGSearchConfig(BaseSearchConfig): Returns: SearchResponse with standardized format """ + if not 200 <= raw_response.status_code < 300: + # The HTTP handlers for this provider do not call raise_for_status, + # so an upstream 429/503 would otherwise be parsed as a successful + # response with zero results. The upstream body is deliberately not + # echoed, because it can contain the original query. + raise self.get_error_class( + error_message=(f"SearXNG search provider returned HTTP {raw_response.status_code}"), + status_code=raw_response.status_code, + headers=dict(raw_response.headers), # mutable-ok: get_error_class takes a plain dict + ) + response_json: Final = raw_response.json() # Transform results to SearchResult objects diff --git a/tests/test_litellm/llms/searxng/search/test_searxng_transformation.py b/tests/test_litellm/llms/searxng/search/test_searxng_transformation.py new file mode 100644 index 00000000000..d5208f78c0e --- /dev/null +++ b/tests/test_litellm/llms/searxng/search/test_searxng_transformation.py @@ -0,0 +1,72 @@ +""" +Regression tests for HTTP status handling in ``SearXNGSearchConfig``. + +The adapter used to build a ``SearchResponse`` from ``raw_response.json()`` +without inspecting the status code. A 429 or 503 has no ``results`` key, so the +caller received a successful response with zero results — indistinguishable +from a query that genuinely matched nothing, which meant fallback between +configured search tools never fired. +""" + +from unittest.mock import MagicMock + +import httpx +import pytest + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.searxng.search.transformation import SearXNGSearchConfig + + +@pytest.fixture +def config() -> SearXNGSearchConfig: + return SearXNGSearchConfig() + + +@pytest.fixture +def logging_obj() -> MagicMock: + return MagicMock() + + +def _response(status_code: int, payload: dict) -> httpx.Response: + return httpx.Response( + status_code=status_code, + json=payload, + request=httpx.Request("GET", "https://searxng.example.com/search"), + ) + + +@pytest.mark.parametrize("status_code", [429, 503]) +def test_retryable_status_is_raised_not_parsed_as_empty( + config: SearXNGSearchConfig, logging_obj: MagicMock, status_code: int +) -> None: + """A rate-limited or unavailable instance must surface as an error.""" + with pytest.raises(BaseLLMException) as exc_info: + config.transform_search_response( + raw_response=_response(status_code, {"error": "upstream unavailable"}), + logging_obj=logging_obj, + ) + + assert exc_info.value.status_code == status_code + assert str(status_code) in str(exc_info.value) + + +def test_error_message_does_not_echo_upstream_body(config: SearXNGSearchConfig, logging_obj: MagicMock) -> None: + """The upstream body can echo the original query; keep it out of the error.""" + with pytest.raises(BaseLLMException) as exc_info: + config.transform_search_response( + raw_response=_response(500, {"error": "failed searching for confidential-term"}), + logging_obj=logging_obj, + ) + + assert "confidential-term" not in str(exc_info.value) + + +def test_success_status_still_parses_normally(config: SearXNGSearchConfig, logging_obj: MagicMock) -> None: + """2xx behaviour is unchanged.""" + response = config.transform_search_response( + raw_response=_response(200, {"results": [{"title": "Still works", "url": "https://example.com"}]}), + logging_obj=logging_obj, + ) + + assert len(response.results) == 1 + assert response.results[0].title == "Still works"