Spaces:
Running
Running
File size: 20,254 Bytes
e44fdef 73c1aa6 e44fdef f493ed5 73c1aa6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 | """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"
|