Claude Claude Opus 5 commited on
Commit
73c1aa6
·
unverified ·
1 Parent(s): fabb9dc

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

Files changed (3) hide show
  1. CONTRIBUTING.md +1 -1
  2. services.py +44 -16
  3. tests/test_search_service.py +150 -1
CONTRIBUTING.md CHANGED
@@ -30,7 +30,7 @@ ruff check . # same lint CI runs
30
  pytest
31
  ```
32
 
33
- 264 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
 
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
services.py CHANGED
@@ -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 success."""
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- last_error: Optional[Exception] = None
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
- last_error = e
218
  continue
219
 
220
  try:
221
  logger.info(f"Querying {name} with query: `{q}`")
222
  result = await call()
223
- self._breaker.record_success(name)
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 to the next backend, but don't
228
- # hold it against this one: the breaker is shared across
229
- # every request, so counting it would let a few
230
- # unusual-but-legitimate queries disable the primary backend
231
- # for everyone. A hard block normally raises a real error,
232
- # which is handled below.
233
  logger.info(f"{name} returned no results for query `{q}`")
234
- last_error = e
 
235
  except Exception as e:
236
  logger.error(f"Failed to query {name} with query `{q}`: {e}")
237
  self._breaker.record_failure(name)
238
- last_error = e
 
 
 
 
 
 
 
 
 
 
 
 
 
239
 
240
- return q, [], f"All backends failed for query '{q}': {last_error}"
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 "All backends are rate-limited.")
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
 
tests/test_search_service.py CHANGED
@@ -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 "All backends failed for query 'widget'" in err
 
 
 
 
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"