Spaces:
Running
Stop the search chain treating an empty answer as an answer
Browse files`/serp/search` returned zero results and the explanation "All backends are
rate-limited." - a claim with nothing behind it. Seen once against the
live deployment, then not reproducible, which is itself the problem: the
message described a diagnosis rather than what happened.
Two causes, both fixed here.
A backend that returned an empty list without raising ended the fallback
chain and was recorded as a success. Brave does that whenever its block
page lacks the word "suspicious", and Bing whenever no result item yields
a title and href - so one quiet backend could stop the chain before a
working one was ever tried. This is the same bug class fixed for
DuckDuckGo in d341a7a, which raises on empty; the other two never got the
same treatment. Fixing it in the chain rather than in each scraper covers
every backend, including any added later, and leaves the single-backend
endpoints free to report an honest empty result.
An empty answer still counts neither for nor against the backend, as an
exception-raised empty already did: 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.
The error now names what each backend did - "no results", "failed
(cause)", or "skipped (circuit open)". Those are different problems that
lead somewhere different, and collapsing them into one invented diagnosis
sent whoever read it looking for throttling that may not have existed:
No results for query 'widget' (DuckDuckGo: no results; Brave Search:
failed (connection reset by peer); Bing: skipped (Circuit open for
'Bing'; retry after 60s))
Mutation testing showed `search`'s own fallback text was unreachable once
_search_one always explains itself - which is how the rate-limit guess
survived unnoticed. Rather than delete the defensive branch, there is now
a test that reaches it, so it has to say something defensible too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e
- CONTRIBUTING.md +1 -1
- services.py +44 -16
- tests/test_search_service.py +150 -1
|
@@ -30,7 +30,7 @@ ruff check . # same lint CI runs
|
|
| 30 |
pytest
|
| 31 |
```
|
| 32 |
|
| 33 |
-
|
| 34 |
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
|
| 35 |
Google Patents search) run against a real headless Chromium but navigate to
|
| 36 |
local fixture HTML instead of the live sites — see `tests/helpers.py` for
|
|
|
|
| 30 |
pytest
|
| 31 |
```
|
| 32 |
|
| 33 |
+
271 tests, a few seconds, fully offline. All outbound HTTP is mocked with
|
| 34 |
`respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
|
| 35 |
Google Patents search) run against a real headless Chromium but navigate to
|
| 36 |
local fixture HTML instead of the live sites — see `tests/helpers.py` for
|
|
@@ -202,42 +202,66 @@ class SearchService:
|
|
| 202 |
|
| 203 |
async def _search_one(self, q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
| 204 |
"""Try DDG, then Brave, then Bing for a single query; stop at the
|
| 205 |
-
first
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
backends = [
|
| 207 |
("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
|
| 208 |
("Brave Search", lambda: query_brave_search(self.browser, q, n_results)),
|
| 209 |
("Bing", lambda: query_bing_search(self.browser, q, n_results)),
|
| 210 |
]
|
| 211 |
-
|
| 212 |
for name, call in backends:
|
| 213 |
try:
|
| 214 |
self._breaker.before_call(name)
|
| 215 |
except CircuitOpenError as e:
|
| 216 |
logger.info(f"Skipping {name} for query `{q}`: {e}")
|
| 217 |
-
|
| 218 |
continue
|
| 219 |
|
| 220 |
try:
|
| 221 |
logger.info(f"Querying {name} with query: `{q}`")
|
| 222 |
result = await call()
|
| 223 |
-
|
| 224 |
-
return q, result, None
|
| 225 |
-
except EmptyResultsError as e:
|
| 226 |
# Zero results is ambiguous - no hits, or a soft block that
|
| 227 |
-
# parsed to nothing. Move on
|
| 228 |
-
#
|
| 229 |
-
#
|
| 230 |
-
#
|
| 231 |
-
#
|
| 232 |
-
# which is handled below.
|
| 233 |
logger.info(f"{name} returned no results for query `{q}`")
|
| 234 |
-
|
|
|
|
| 235 |
except Exception as e:
|
| 236 |
logger.error(f"Failed to query {name} with query `{q}`: {e}")
|
| 237 |
self._breaker.record_failure(name)
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
|
| 240 |
-
return q, [], f"
|
| 241 |
|
| 242 |
async def search(self, params: SerpQuery) -> SerpResults:
|
| 243 |
"""Search every query across the full backend fallback chain."""
|
|
@@ -252,9 +276,13 @@ class SearchService:
|
|
| 252 |
errors.append(err)
|
| 253 |
|
| 254 |
if len(results) == 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
return SerpResults(
|
| 256 |
results=[],
|
| 257 |
-
error="; ".join(errors) if errors else "
|
| 258 |
|
| 259 |
return SerpResults(results=results, error="; ".join(errors) if errors else None)
|
| 260 |
|
|
|
|
| 202 |
|
| 203 |
async def _search_one(self, q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
|
| 204 |
"""Try DDG, then Brave, then Bing for a single query; stop at the
|
| 205 |
+
first backend that actually returns something.
|
| 206 |
+
|
| 207 |
+
A backend that answers with an empty list has not answered the
|
| 208 |
+
question - Brave does that whenever its block page lacks the word
|
| 209 |
+
"suspicious", and Bing whenever no result item yields a title and
|
| 210 |
+
href - so the chain continues rather than reporting success with
|
| 211 |
+
zero results.
|
| 212 |
+
|
| 213 |
+
When nothing produces results, the error names what each backend
|
| 214 |
+
did. "Failed", "returned nothing" and "never asked" are different
|
| 215 |
+
problems that lead somewhere different, and collapsing them into
|
| 216 |
+
one guess is how this previously reported rate limiting it had no
|
| 217 |
+
evidence for.
|
| 218 |
+
"""
|
| 219 |
backends = [
|
| 220 |
("DuckDuckGo", lambda: query_ddg_search(q, n_results)),
|
| 221 |
("Brave Search", lambda: query_brave_search(self.browser, q, n_results)),
|
| 222 |
("Bing", lambda: query_bing_search(self.browser, q, n_results)),
|
| 223 |
]
|
| 224 |
+
outcomes: list[str] = []
|
| 225 |
for name, call in backends:
|
| 226 |
try:
|
| 227 |
self._breaker.before_call(name)
|
| 228 |
except CircuitOpenError as e:
|
| 229 |
logger.info(f"Skipping {name} for query `{q}`: {e}")
|
| 230 |
+
outcomes.append(f"{name}: skipped ({e})")
|
| 231 |
continue
|
| 232 |
|
| 233 |
try:
|
| 234 |
logger.info(f"Querying {name} with query: `{q}`")
|
| 235 |
result = await call()
|
| 236 |
+
except EmptyResultsError:
|
|
|
|
|
|
|
| 237 |
# Zero results is ambiguous - no hits, or a soft block that
|
| 238 |
+
# parsed to nothing. Move on, but don't hold it against
|
| 239 |
+
# this backend: the breaker is shared across every request,
|
| 240 |
+
# so counting it would let a few unusual-but-legitimate
|
| 241 |
+
# queries disable the primary backend for everyone. A hard
|
| 242 |
+
# block normally raises a real error, handled below.
|
|
|
|
| 243 |
logger.info(f"{name} returned no results for query `{q}`")
|
| 244 |
+
outcomes.append(f"{name}: no results")
|
| 245 |
+
continue
|
| 246 |
except Exception as e:
|
| 247 |
logger.error(f"Failed to query {name} with query `{q}`: {e}")
|
| 248 |
self._breaker.record_failure(name)
|
| 249 |
+
outcomes.append(f"{name}: failed ({e})")
|
| 250 |
+
continue
|
| 251 |
+
|
| 252 |
+
if result:
|
| 253 |
+
self._breaker.record_success(name)
|
| 254 |
+
return q, result, None
|
| 255 |
+
|
| 256 |
+
# Answered, but with nothing. Same ambiguity as the exception
|
| 257 |
+
# above, so it counts neither for nor against the backend -
|
| 258 |
+
# recording it as a success would reset one part-way to
|
| 259 |
+
# tripping its breaker, which is how a soft-blocking backend
|
| 260 |
+
# stays in rotation forever.
|
| 261 |
+
logger.info(f"{name} returned an empty result set for query `{q}`")
|
| 262 |
+
outcomes.append(f"{name}: no results")
|
| 263 |
|
| 264 |
+
return q, [], f"No results for query '{q}' ({'; '.join(outcomes)})"
|
| 265 |
|
| 266 |
async def search(self, params: SerpQuery) -> SerpResults:
|
| 267 |
"""Search every query across the full backend fallback chain."""
|
|
|
|
| 276 |
errors.append(err)
|
| 277 |
|
| 278 |
if len(results) == 0:
|
| 279 |
+
# _search_one always explains itself when it produces nothing,
|
| 280 |
+
# so `errors` is populated here; the fallback text is a plain
|
| 281 |
+
# statement of fact rather than the invented rate-limit
|
| 282 |
+
# diagnosis it replaced.
|
| 283 |
return SerpResults(
|
| 284 |
results=[],
|
| 285 |
+
error="; ".join(errors) if errors else "No results found.")
|
| 286 |
|
| 287 |
return SerpResults(results=results, error="; ".join(errors) if errors else None)
|
| 288 |
|
|
@@ -245,7 +245,11 @@ async def test_reports_an_error_when_every_backend_fails(monkeypatch):
|
|
| 245 |
q, results, err = await make_service()._search_one("widget", 5)
|
| 246 |
|
| 247 |
assert results == []
|
| 248 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
assert "blocked" in err
|
| 250 |
|
| 251 |
|
|
@@ -408,3 +412,148 @@ async def test_a_scholar_block_is_reported_as_such_to_the_caller(monkeypatch):
|
|
| 408 |
assert result.results == []
|
| 409 |
assert "blocked" in result.error.lower()
|
| 410 |
assert "timeout" not in result.error.lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
q, results, err = await make_service()._search_one("widget", 5)
|
| 246 |
|
| 247 |
assert results == []
|
| 248 |
+
assert "widget" in err
|
| 249 |
+
# Each backend is named with what it did, rather than collapsed into a
|
| 250 |
+
# single "all backends failed" with only the last error attached.
|
| 251 |
+
for backend in ("DuckDuckGo", "Brave Search", "Bing"):
|
| 252 |
+
assert f"{backend}: failed" in err
|
| 253 |
assert "blocked" in err
|
| 254 |
|
| 255 |
|
|
|
|
| 412 |
assert result.results == []
|
| 413 |
assert "blocked" in result.error.lower()
|
| 414 |
assert "timeout" not in result.error.lower()
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
# ------------------- an empty answer is not an answer -------------------
|
| 418 |
+
# A backend returning [] without raising used to end the fallback chain and
|
| 419 |
+
# be reported as success. Brave does that whenever its block page lacks the
|
| 420 |
+
# word "suspicious", and Bing whenever no result item yields a title+href.
|
| 421 |
+
# The caller then got zero results and the invented explanation "All
|
| 422 |
+
# backends are rate-limited." - a guess, not evidence. Observed once
|
| 423 |
+
# against the live deployment.
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
async def test_an_empty_backend_result_falls_through_to_the_next_backend(monkeypatch):
|
| 427 |
+
calls = []
|
| 428 |
+
|
| 429 |
+
async def empty_ddg(q, n):
|
| 430 |
+
calls.append("ddg")
|
| 431 |
+
return []
|
| 432 |
+
|
| 433 |
+
async def fake_brave(browser, q, n):
|
| 434 |
+
calls.append("brave")
|
| 435 |
+
return [{"title": "from brave"}]
|
| 436 |
+
|
| 437 |
+
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
|
| 438 |
+
monkeypatch.setattr(services_module, "query_brave_search", fake_brave)
|
| 439 |
+
|
| 440 |
+
_q, results, err = await make_service()._search_one("widget", 5)
|
| 441 |
+
|
| 442 |
+
assert calls == ["ddg", "brave"], "an empty result ended the chain"
|
| 443 |
+
assert results == [{"title": "from brave"}]
|
| 444 |
+
assert err is None
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
async def test_every_backend_empty_reports_that_and_not_rate_limiting(monkeypatch):
|
| 448 |
+
"""The message has to describe what happened. "Rate-limited" was
|
| 449 |
+
asserted without evidence, and sent whoever read it looking for a
|
| 450 |
+
throttling problem that may not exist.
|
| 451 |
+
"""
|
| 452 |
+
async def empty(*args):
|
| 453 |
+
return []
|
| 454 |
+
|
| 455 |
+
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
|
| 456 |
+
monkeypatch.setattr(services_module, name, empty)
|
| 457 |
+
|
| 458 |
+
_q, results, err = await make_service()._search_one("widget", 5)
|
| 459 |
+
|
| 460 |
+
assert results == []
|
| 461 |
+
assert "rate-limit" not in err.lower()
|
| 462 |
+
for backend in ("DuckDuckGo", "Brave Search", "Bing"):
|
| 463 |
+
assert backend in err, f"{backend}'s outcome is missing from {err!r}"
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
async def test_the_error_distinguishes_failing_from_returning_nothing(monkeypatch):
|
| 467 |
+
""""Failed" and "answered with nothing" are different problems and lead
|
| 468 |
+
somewhere different - one is an outage, the other may just be an
|
| 469 |
+
obscure query."""
|
| 470 |
+
async def failing_ddg(q, n):
|
| 471 |
+
raise RuntimeError("connection reset")
|
| 472 |
+
|
| 473 |
+
async def empty_brave(browser, q, n):
|
| 474 |
+
return []
|
| 475 |
+
|
| 476 |
+
async def failing_bing(browser, q, n):
|
| 477 |
+
raise RuntimeError("connection reset")
|
| 478 |
+
|
| 479 |
+
monkeypatch.setattr(services_module, "query_ddg_search", failing_ddg)
|
| 480 |
+
monkeypatch.setattr(services_module, "query_brave_search", empty_brave)
|
| 481 |
+
monkeypatch.setattr(services_module, "query_bing_search", failing_bing)
|
| 482 |
+
|
| 483 |
+
_q, _results, err = await make_service()._search_one("widget", 5)
|
| 484 |
+
|
| 485 |
+
assert "connection reset" in err, "a real failure must still surface its cause"
|
| 486 |
+
assert "no results" in err.lower(), "an empty answer must be described as such"
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
async def test_a_skipped_backend_is_reported_as_skipped_not_failed(monkeypatch):
|
| 490 |
+
"""A backend whose circuit is open was never asked, so saying it failed
|
| 491 |
+
for this query is wrong."""
|
| 492 |
+
async def empty(*args):
|
| 493 |
+
return []
|
| 494 |
+
|
| 495 |
+
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
|
| 496 |
+
monkeypatch.setattr(services_module, name, empty)
|
| 497 |
+
|
| 498 |
+
breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
|
| 499 |
+
breaker.record_failure("Bing")
|
| 500 |
+
service = make_service(breaker=breaker)
|
| 501 |
+
|
| 502 |
+
_q, _results, err = await service._search_one("widget", 5)
|
| 503 |
+
|
| 504 |
+
assert "skipped" in err.lower()
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
async def test_an_empty_answer_does_not_clear_an_accumulating_failure_count(monkeypatch):
|
| 508 |
+
"""Empty is ambiguous, so it is evidence neither way. Recording it as a
|
| 509 |
+
success would reset a backend part-way to tripping its breaker, which
|
| 510 |
+
is how a soft-blocking backend stays in rotation forever.
|
| 511 |
+
"""
|
| 512 |
+
breaker = CircuitBreaker(failure_threshold=2, cooldown_seconds=60)
|
| 513 |
+
breaker.record_failure("DuckDuckGo") # one strike already
|
| 514 |
+
|
| 515 |
+
async def empty_ddg(q, n):
|
| 516 |
+
return []
|
| 517 |
+
|
| 518 |
+
monkeypatch.setattr(services_module, "query_ddg_search", empty_ddg)
|
| 519 |
+
monkeypatch.setattr(
|
| 520 |
+
services_module, "query_brave_search", lambda b, q, n: _ok([{"title": "brave"}]))
|
| 521 |
+
|
| 522 |
+
await make_service(breaker=breaker)._search_one("widget", 5)
|
| 523 |
+
|
| 524 |
+
assert breaker._states["DuckDuckGo"].failure_count == 1, (
|
| 525 |
+
"an empty result cleared DuckDuckGo's failure count")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
async def test_search_never_invents_a_rate_limit_explanation(monkeypatch):
|
| 529 |
+
"""The endpoint-level guard for the same thing."""
|
| 530 |
+
async def empty(*args):
|
| 531 |
+
return []
|
| 532 |
+
|
| 533 |
+
for name in ("query_ddg_search", "query_brave_search", "query_bing_search"):
|
| 534 |
+
monkeypatch.setattr(services_module, name, empty)
|
| 535 |
+
|
| 536 |
+
result = await make_service().search(SerpQuery(queries=["a", "b"]))
|
| 537 |
+
|
| 538 |
+
assert result.results == []
|
| 539 |
+
assert result.error and "rate-limit" not in result.error.lower()
|
| 540 |
+
|
| 541 |
+
|
| 542 |
+
async def test_the_aggregate_fallback_message_states_a_fact_not_a_diagnosis():
|
| 543 |
+
"""`_search_one` always explains itself, so `search`'s fallback text is
|
| 544 |
+
unreachable in practice - which is exactly how the rate-limit guess
|
| 545 |
+
survived unnoticed for so long. Reach it directly so the defensive
|
| 546 |
+
branch says something defensible too.
|
| 547 |
+
"""
|
| 548 |
+
service = make_service()
|
| 549 |
+
|
| 550 |
+
async def silent_search_one(q, n_results):
|
| 551 |
+
return q, [], None
|
| 552 |
+
|
| 553 |
+
service._search_one = silent_search_one
|
| 554 |
+
|
| 555 |
+
result = await service.search(SerpQuery(queries=["a"]))
|
| 556 |
+
|
| 557 |
+
assert result.results == []
|
| 558 |
+
assert "rate-limit" not in result.error.lower()
|
| 559 |
+
assert result.error, "an empty result set must still carry an explanation"
|