fix(searxng): preserve upstream HTTP errors instead of returning empty results

The search adapter built a SearchResponse from raw_response.json() without
checking the status code. A 429 or 503 from the SearXNG instance has no
"results" key, so response_json.get("results", []) produced an empty list and
the caller received a successful response with zero results.

That makes a retryable provider failure indistinguishable from a query that
genuinely matched nothing, so fallback between configured search tools never
fires. The HTTP handlers for this provider do not call raise_for_status either,
so no other layer inspects the status.

Raise through the provider's own error class so the existing retry and fallback
machinery classifies it. The upstream body is deliberately not included in the
message, because it can echo the original query.

Tests live under tests/test_litellm/, mirroring the module path, because that
is the tree the unit-test workflow executes — tests/search_tests/ is not run by
any workflow, so coverage there would not reach the changed lines.
This commit is contained in:
Eliel Sousa 2026-08-28 09:48:31 -03:00
parent bd7e9c1997
commit acf6a72ada
2 changed files with 83 additions and 0 deletions

View file

@ -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

View file

@ -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"