SERPent / tests /test_search_service.py
Claude
Claude Opus 5
Stop the search chain treating an empty answer as an answer
73c1aa6 unverified
Raw History Blame Contribute Delete
20.3 kB
"""SearchService: the backend orchestration policy.
Which backend to try, in what order, when to fall back, and what counts as
evidence a backend is unhealthy. None of this needs an HTTP layer, so none
of these tests use one.
Each test builds a service with its own circuit breaker. That replaces the
autouse fixture that used to reset a process-wide singleton between tests -
the breaker being shared across every request is correct in production and
was only a problem because tests had no way to opt out of it.
"""
import services as services_module
from circuit_breaker import CircuitBreaker
from serp import DuckDuckGoBlockedException, SerpQuery
from services import SearchService, shape_serp_results
class FakeOPSTokens:
def __init__(self, configured: bool):
self.configured = configured
def make_service(*, ops_configured: bool = False, breaker: CircuitBreaker = None,
browser=None) -> SearchService:
return SearchService(
http_client=None,
browser_provider=lambda: browser,
circuit_breaker=breaker or CircuitBreaker(),
ops_tokens=FakeOPSTokens(ops_configured),
)
# --------------------------------- result shaping ---------------------------------
class TestShapeSerpResults:
def test_flattens_successful_lists(self):
result = shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
assert result.error is None
def test_surfaces_last_error_when_all_failed(self):
result = shape_serp_results([ValueError("first"), RuntimeError("second")])
assert result.results == []
assert "second" in result.error
def test_ignores_failures_when_some_succeed(self):
result = shape_serp_results([[{"a": 1}], RuntimeError("boom")])
assert result.results == [{"a": 1}]
assert result.error is None
def test_is_total_for_an_empty_list(self):
"""Defence in depth: the request models bound the query list, but
this helper is shared by every search path and must not depend on
its caller having validated anything."""
result = shape_serp_results([])
assert result.results == []
assert result.error is not None
# ------------------------------- per-query dispatch -------------------------------
async def test_every_query_is_dispatched_with_the_result_count():
calls = []
async def fn(q, n):
calls.append((q, n))
return [{"q": q}]
result = await make_service()._run_queries(
fn, SerpQuery(queries=["x", "y"], n_results=5), "test context")
assert sorted(calls) == [("x", 5), ("y", 5)]
assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
async def test_arxiv_flattens_results_from_all_queries(monkeypatch):
async def fake_query_arxiv(client, q, n):
return [{"title": f"paper about {q}"}]
monkeypatch.setattr(services_module, "query_arxiv", fake_query_arxiv)
result = await make_service().arxiv(SerpQuery(queries=["a", "b"], n_results=5))
assert {r["title"] for r in result.results} == {"paper about a", "paper about b"}
assert result.error is None
async def test_arxiv_reports_an_error_when_every_query_fails(monkeypatch):
async def failing(client, q, n):
raise RuntimeError(f"boom for {q}")
monkeypatch.setattr(services_module, "query_arxiv", failing)
result = await make_service().arxiv(SerpQuery(queries=["a"], n_results=5))
assert result.results == []
assert "boom for a" in result.error
# ------------------------------ patents + OPS fallback ------------------------------
async def test_patents_does_not_fall_back_when_google_patents_succeeds(monkeypatch):
ops_called = False
async def fake_ops(client, q, n):
nonlocal ops_called
ops_called = True
return []
monkeypatch.setattr(
services_module, "query_google_patents",
lambda b, q, n: _ok([{"id": "US1", "title": "t"}]))
monkeypatch.setattr(services_module, "ops_search", fake_ops)
result = await make_service(ops_configured=True).patents(
SerpQuery(queries=["widget"], n_results=5))
assert result.results == [{"id": "US1", "title": "t"}]
assert ops_called is False
async def _ok(value):
return value
async def test_patents_falls_back_to_ops_when_google_patents_is_empty(monkeypatch):
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
monkeypatch.setattr(
services_module, "ops_search", lambda c, q, n: _ok([{"id": "US123", "title": "OPS"}]))
result = await make_service(ops_configured=True).patents(
SerpQuery(queries=["widget"], n_results=5))
assert result.results == [{"id": "US123", "title": "OPS"}]
async def test_patents_does_not_fall_back_when_ops_is_unconfigured(monkeypatch):
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
result = await make_service(ops_configured=False).patents(
SerpQuery(queries=["widget"], n_results=5))
assert result.results == []
assert result.error is None
async def test_ops_fallback_runs_concurrently_not_one_query_at_a_time(monkeypatch):
"""This path exists for when Google Patents is unavailable, which is
exactly when a request can least afford N sequential round-trips to the
slower backend."""
import asyncio
in_flight = 0
peak = 0
async def slow_ops(client, q, n):
nonlocal in_flight, peak
in_flight += 1
peak = max(peak, in_flight)
await asyncio.sleep(0.02)
in_flight -= 1
return [{"id": q}]
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
monkeypatch.setattr(services_module, "ops_search", slow_ops)
await make_service(ops_configured=True).patents(
SerpQuery(queries=["a", "b", "c", "d"], n_results=5))
assert peak > 1, "OPS fallback queries were issued one at a time"
async def test_a_failing_ops_fallback_does_not_lose_other_queries_results(monkeypatch):
async def selective_ops(client, q, n):
if q == "bad":
raise RuntimeError("OPS down")
return [{"id": q}]
monkeypatch.setattr(services_module, "query_google_patents", lambda b, q, n: _ok([]))
monkeypatch.setattr(services_module, "ops_search", selective_ops)
result = await make_service(ops_configured=True).patents(
SerpQuery(queries=["good", "bad"], n_results=5))
assert result.results == [{"id": "good"}]
# -------------------------------- the fallback chain --------------------------------
async def test_returns_the_first_backend_that_succeeds(monkeypatch):
calls = []
async def fake_ddg(q, n):
calls.append("ddg")
return [{"title": "from ddg"}]
async def fake_brave(browser, q, n):
calls.append("brave")
return [{"title": "from brave"}]
monkeypatch.setattr(services_module, "query_ddg_search", fake_ddg)
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
q, results, err = await make_service()._search_one("widget", 5)
assert calls == ["ddg"]
assert results == [{"title": "from ddg"}]
assert err is None
async def test_falls_through_to_the_next_backend_on_failure(monkeypatch):
calls = []
async def failing_ddg(q, n):
calls.append("ddg")
raise RuntimeError("ddg blocked")
async def fake_brave(browser, q, n):
calls.append("brave")
return [{"title": "from brave"}]
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
q, results, err = await make_service()._search_one("widget", 5)
assert calls == ["ddg", "brave"]
assert results == [{"title": "from brave"}]
assert err is None
async def test_reports_an_error_when_every_backend_fails(monkeypatch):
async def failing(*args):
raise RuntimeError("blocked")
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
monkeypatch.setattr(services_module, name, failing)
q, results, err = await make_service()._search_one("widget", 5)
assert results == []
assert "widget" in err
# Each backend is named with what it did, rather than collapsed into a
# single "all backends failed" with only the last error attached.
for backend in ("DuckDuckGo", "Brave Search", "Bing"):
assert f"{backend}: failed" in err
assert "blocked" in err
# ------------------------------ the circuit breaker ------------------------------
async def test_skips_a_backend_whose_circuit_is_open(monkeypatch):
"""A backend that's failed `failure_threshold` times in a row shouldn't
be attempted again until its cooldown elapses - repeatedly hammering a
backend that's already rate-limiting us only makes that worse.
"""
ddg_calls = []
async def failing_ddg(q, n):
ddg_calls.append(q)
raise RuntimeError("ddg blocked")
async def fake_brave(browser, q, n):
return [{"title": "from brave"}]
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
await service._search_one("first query", 5)
assert ddg_calls == ["first query"]
q, results, err = await service._search_one("second query", 5)
assert ddg_calls == ["first query"] # unchanged - DDG was skipped
assert results == [{"title": "from brave"}]
async def test_a_zero_result_query_does_not_open_the_circuit(monkeypatch):
"""An empty result is ambiguous - it can mean the query genuinely has no
hits, or that the backend served an anti-bot page that parsed to
nothing. The breaker is shared across every request, so counting the
ambiguous case meant a few unusual-but-legitimate queries could disable
the primary backend for everyone for a full cooldown.
"""
ddg_calls = []
async def empty_ddg(q, n):
ddg_calls.append(q)
raise DuckDuckGoBlockedException()
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
monkeypatch.setattr(
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
await service._search_one("obscure", 5)
await service._search_one("another obscure", 5)
assert ddg_calls == ["obscure", "another obscure"]
async def test_an_empty_result_still_falls_through_to_the_next_backend(monkeypatch):
"""Not counting it against the backend must not stop the fallback: the
caller still needs results from somewhere."""
async def empty_ddg(q, n):
raise DuckDuckGoBlockedException()
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
monkeypatch.setattr(
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "from brave"}]))
_q, results, err = await make_service()._search_one("widget", 5)
assert results == [{"title": "from brave"}]
assert err is None
async def test_a_real_backend_error_still_opens_the_circuit(monkeypatch):
"""The distinction has to cut both ways: a backend raising a genuine
error (a rate limit, a transport failure) is still evidence."""
ddg_calls = []
async def failing_ddg(q, n):
ddg_calls.append(q)
raise RuntimeError("rate limited")
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
monkeypatch.setattr(
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
service = make_service(breaker=CircuitBreaker(failure_threshold=1, cooldown_seconds=60))
await service._search_one("first", 5)
await service._search_one("second", 5)
assert ddg_calls == ["first"]
# ------------------------------- search aggregation -------------------------------
async def test_search_merges_results_from_every_query(monkeypatch):
monkeypatch.setattr(
services_module, "query_ddg_search", lambda q, n: _ok([{"title": f"result for {q}"}]))
result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5))
assert {r["title"] for r in result.results} == {"result for a", "result for b"}
assert result.error is None
async def test_search_reports_partial_failure_alongside_results(monkeypatch):
"""A query no backend could answer must be reported even when another
query succeeded - otherwise the caller silently receives fewer results
than they asked for with no indication why."""
async def selective_ddg(q, n):
if q == "bad":
raise RuntimeError("blocked")
return [{"title": f"result for {q}"}]
async def failing(*args):
raise RuntimeError("blocked")
monkeypatch.setattr(services_module, "query_ddg_search", selective_ddg)
monkeypatch.setattr(services_module, "query_brave_search", failing)
monkeypatch.setattr(services_module, "query_bing_search", failing)
result = await make_service().search(SerpQuery(queries=["good", "bad"], n_results=5))
assert [r["title"] for r in result.results] == ["result for good"]
assert "bad" in result.error
async def test_search_names_every_failed_query_not_just_the_last(monkeypatch):
async def failing(*args):
raise RuntimeError("blocked")
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
monkeypatch.setattr(services_module, name, failing)
result = await make_service().search(SerpQuery(queries=["a", "b"], n_results=5))
assert result.results == []
assert "'a'" in result.error and "'b'" in result.error
async def test_a_scholar_block_is_reported_as_such_to_the_caller(monkeypatch):
"""The point of the dedicated exception is the message the caller sees:
"Google Scholar blocked the request" tells an operator their IP is
rate-limited, where the bare "Timeout 30000ms exceeded" it replaced read
like a broken selector.
"""
from serp import GoogleScholarBlockedException
async def blocked(browser, q, n):
raise GoogleScholarBlockedException()
monkeypatch.setattr(services_module, "query_google_scholar", blocked)
result = await make_service().google_scholar(SerpQuery(queries=["anything"]))
assert result.results == []
assert "blocked" in result.error.lower()
assert "timeout" not in result.error.lower()
# ------------------- an empty answer is not an answer -------------------
# A backend returning [] without raising used to end the fallback chain and
# be reported as success. Brave does that whenever its block page lacks the
# word "suspicious", and Bing whenever no result item yields a title+href.
# The caller then got zero results and the invented explanation "All
# backends are rate-limited." - a guess, not evidence. Observed once
# against the live deployment.
async def test_an_empty_backend_result_falls_through_to_the_next_backend(monkeypatch):
calls = []
async def empty_ddg(q, n):
calls.append("ddg")
return []
async def fake_brave(browser, q, n):
calls.append("brave")
return [{"title": "from brave"}]
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
_q, results, err = await make_service()._search_one("widget", 5)
assert calls == ["ddg", "brave"], "an empty result ended the chain"
assert results == [{"title": "from brave"}]
assert err is None
async def test_every_backend_empty_reports_that_and_not_rate_limiting(monkeypatch):
"""The message has to describe what happened. "Rate-limited" was
asserted without evidence, and sent whoever read it looking for a
throttling problem that may not exist.
"""
async def empty(*args):
return []
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
monkeypatch.setattr(services_module, name, empty)
_q, results, err = await make_service()._search_one("widget", 5)
assert results == []
assert "rate-limit" not in err.lower()
for backend in ("DuckDuckGo", "Brave Search", "Bing"):
assert backend in err, f"{backend}'s outcome is missing from {err!r}"
async def test_the_error_distinguishes_failing_from_returning_nothing(monkeypatch):
""""Failed" and "answered with nothing" are different problems and lead
somewhere different - one is an outage, the other may just be an
obscure query."""
async def failing_ddg(q, n):
raise RuntimeError("connection reset")
async def empty_brave(browser, q, n):
return []
async def failing_bing(browser, q, n):
raise RuntimeError("connection reset")
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
monkeypatch.setattr(services_module, "query_brave_search", empty_brave)
monkeypatch.setattr(services_module, "query_bing_search", failing_bing)
_q, _results, err = await make_service()._search_one("widget", 5)
assert "connection reset" in err, "a real failure must still surface its cause"
assert "no results" in err.lower(), "an empty answer must be described as such"
async def test_a_skipped_backend_is_reported_as_skipped_not_failed(monkeypatch):
"""A backend whose circuit is open was never asked, so saying it failed
for this query is wrong."""
async def empty(*args):
return []
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
monkeypatch.setattr(services_module, name, empty)
breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
breaker.record_failure("Bing")
service = make_service(breaker=breaker)
_q, _results, err = await service._search_one("widget", 5)
assert "skipped" in err.lower()
async def test_an_empty_answer_does_not_clear_an_accumulating_failure_count(monkeypatch):
"""Empty is ambiguous, so it is evidence neither way. Recording it as a
success would reset a backend part-way to tripping its breaker, which
is how a soft-blocking backend stays in rotation forever.
"""
breaker = CircuitBreaker(failure_threshold=2, cooldown_seconds=60)
breaker.record_failure("DuckDuckGo") # one strike already
async def empty_ddg(q, n):
return []
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
monkeypatch.setattr(
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
await make_service(breaker=breaker)._search_one("widget", 5)
assert breaker._states["DuckDuckGo"].failure_count == 1, (
"an empty result cleared DuckDuckGo's failure count")
async def test_search_never_invents_a_rate_limit_explanation(monkeypatch):
"""The endpoint-level guard for the same thing."""
async def empty(*args):
return []
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
monkeypatch.setattr(services_module, name, empty)
result = await make_service().search(SerpQuery(queries=["a", "b"]))
assert result.results == []
assert result.error and "rate-limit" not in result.error.lower()
async def test_the_aggregate_fallback_message_states_a_fact_not_a_diagnosis():
"""`_search_one` always explains itself, so `search`'s fallback text is
unreachable in practice - which is exactly how the rate-limit guess
survived unnoticed for so long. Reach it directly so the defensive
branch says something defensible too.
"""
service = make_service()
async def silent_search_one(q, n_results):
return q, [], None
service._search_one = silent_search_one
result = await service.search(SerpQuery(queries=["a"]))
assert result.results == []
assert "rate-limit" not in result.error.lower()
assert result.error, "an empty result set must still carry an explanation"