Claude commited on
Commit
ba2278a
·
unverified ·
1 Parent(s): 285bc99

Add a circuit breaker to the multi-backend search fallback (item from review)

Browse files

/serp/search (and the MCP `search` tool) tries DuckDuckGo, then Brave,
then Bing per query with no memory between calls - if a backend is
rate-limiting or has blocked this deployment's IP, every single query
still pays its full timeout hammering that backend before falling
through, which only makes the block worse.

- circuit_breaker.py: a small per-key CircuitBreaker (new module, no third-
party dependency) - opens after `failure_threshold` consecutive failures
for a key, short-circuits further calls during `cooldown_seconds`, then
allows one half-open trial; success closes it, another failure reopens
it for a fresh cooldown. Unit-tested with a fake clock for determinism.
- app.py: wired a module-level `_backend_circuit_breaker` (3 failures,
60s cooldown) into `_search_one`'s backend loop.

_search_one/`/serp/search` had zero test coverage before this - added
characterization tests for its current fallback behavior first (first-
success, falls-through-on-failure, all-fail error format), confirmed
green against the pre-existing code, then added the breaker under that
safety net. Also caught and fixed a latent test-isolation gap while doing
this: `_backend_circuit_breaker` is a real module-level singleton, and
several of the new characterization tests recorded real failures against
it - across enough tests this could have silently pushed a backend's
failure count toward the threshold and made an unrelated later test skip
it. Added an autouse fixture giving every test in test_app.py its own
fresh breaker instance.

Verified the new coverage isn't vacuous: temporarily removed the
before_call check and confirmed the wiring test fails, then restored it.
97 tests pass.

Files changed (4) hide show
  1. app.py +20 -1
  2. circuit_breaker.py +67 -0
  3. tests/test_app.py +104 -0
  4. tests/test_circuit_breaker.py +100 -0
app.py CHANGED
@@ -13,6 +13,7 @@ from pydantic import BaseModel, Field, StringConstraints
13
  from playwright.async_api import async_playwright, Browser, BrowserContext, Page
14
  import uvicorn
15
 
 
16
  from scrap import PatentScrapBulkResponse, PatentScrapResult, scrap_patent_async, scrap_patent_bulk_async
17
  from serp import PATENT_ID_CORE, SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
18
  from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
@@ -206,6 +207,14 @@ async def search_duck(params: SerpQuery) -> SerpResults:
206
  lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
207
 
208
 
 
 
 
 
 
 
 
 
209
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
210
  """Try DDG, then Brave, then Bing for a single query; stop at the first success."""
211
  backends = [
@@ -215,11 +224,21 @@ async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional
215
  ]
216
  last_error: Optional[Exception] = None
217
  for name, call in backends:
 
 
 
 
 
 
 
218
  try:
219
  logging.info(f"Querying {name} with query: `{q}`")
220
- return q, await call(), None
 
 
221
  except Exception as e:
222
  logging.error(f"Failed to query {name} with query `{q}`: {e}")
 
223
  last_error = e
224
 
225
  return q, [], f"All backends failed for query '{q}': {last_error}"
 
13
  from playwright.async_api import async_playwright, Browser, BrowserContext, Page
14
  import uvicorn
15
 
16
+ from circuit_breaker import CircuitBreaker, CircuitOpenError
17
  from scrap import PatentScrapBulkResponse, PatentScrapResult, scrap_patent_async, scrap_patent_bulk_async
18
  from serp import PATENT_ID_CORE, SerpQuery, SerpResults, query_arxiv, query_bing_search, query_brave_search, query_ddg_search, query_google_patents, query_google_scholar
19
  from ops import OPSBulkResponse, OPSNotConfigured, ops_scrap_patent, ops_scrap_patent_bulk, ops_search, token_manager as ops_token_manager
 
207
  lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
208
 
209
 
210
+ # Shared across all queries and requests: once a backend has failed
211
+ # `failure_threshold` times in a row, skip it for `cooldown_seconds` instead
212
+ # of attempting (and paying the timeout cost of) another call that's very
213
+ # likely to fail - and, more importantly, stop hammering a backend that may
214
+ # already be rate-limiting or blocking this deployment's IP.
215
+ _backend_circuit_breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60.0)
216
+
217
+
218
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
219
  """Try DDG, then Brave, then Bing for a single query; stop at the first success."""
220
  backends = [
 
224
  ]
225
  last_error: Optional[Exception] = None
226
  for name, call in backends:
227
+ try:
228
+ _backend_circuit_breaker.before_call(name)
229
+ except CircuitOpenError as e:
230
+ logging.info(f"Skipping {name} for query `{q}`: {e}")
231
+ last_error = e
232
+ continue
233
+
234
  try:
235
  logging.info(f"Querying {name} with query: `{q}`")
236
+ result = await call()
237
+ _backend_circuit_breaker.record_success(name)
238
+ return q, result, None
239
  except Exception as e:
240
  logging.error(f"Failed to query {name} with query `{q}`: {e}")
241
+ _backend_circuit_breaker.record_failure(name)
242
  last_error = e
243
 
244
  return q, [], f"All backends failed for query '{q}': {last_error}"
circuit_breaker.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A minimal per-key circuit breaker for outbound scraping backends.
2
+
3
+ When a backend (e.g. one search engine) is rate-limiting or blocking us,
4
+ retrying it on every single query just makes that worse - it's the
5
+ opposite of what you want when the goal is to stop looking suspicious to
6
+ that backend. This tracks consecutive failures per key and, once a
7
+ threshold is hit, short-circuits further calls to that key for a cooldown
8
+ period instead of attempting (and paying the timeout cost of) a call that
9
+ is very likely to fail anyway. After the cooldown it lets exactly one
10
+ trial call through (half-open); success closes the circuit again, failure
11
+ re-opens it for another full cooldown.
12
+
13
+ Not thread-safe (no locking) - fine given every caller in this codebase
14
+ only ever runs on a single asyncio event loop.
15
+ """
16
+
17
+ import time
18
+ from dataclasses import dataclass
19
+
20
+
21
+ class CircuitOpenError(Exception):
22
+ """Raised by `before_call` instead of letting a call through while a
23
+ key's circuit is open."""
24
+
25
+ def __init__(self, key: str, retry_after: float):
26
+ self.key = key
27
+ self.retry_after = retry_after
28
+ super().__init__(f"Circuit open for '{key}'; retry after {retry_after:.0f}s")
29
+
30
+
31
+ @dataclass
32
+ class _BreakerState:
33
+ failure_count: int = 0
34
+ opened_at: float = 0.0
35
+
36
+
37
+ class CircuitBreaker:
38
+ def __init__(self, failure_threshold: int = 3, cooldown_seconds: float = 60.0):
39
+ self.failure_threshold = failure_threshold
40
+ self.cooldown_seconds = cooldown_seconds
41
+ self._states: dict[str, _BreakerState] = {}
42
+
43
+ def _state(self, key: str) -> _BreakerState:
44
+ return self._states.setdefault(key, _BreakerState())
45
+
46
+ def before_call(self, key: str) -> None:
47
+ """Raise CircuitOpenError if `key` is currently open and its
48
+ cooldown hasn't elapsed. Call this before attempting the call.
49
+ """
50
+ state = self._state(key)
51
+ if state.failure_count < self.failure_threshold:
52
+ return
53
+ remaining = self.cooldown_seconds - (time.monotonic() - state.opened_at)
54
+ if remaining > 0:
55
+ raise CircuitOpenError(key, remaining)
56
+ # Cooldown elapsed: half-open - let this one call through. A
57
+ # renewed failure (record_failure) re-opens it for another cooldown;
58
+ # a success (record_success) clears it entirely.
59
+
60
+ def record_success(self, key: str) -> None:
61
+ self._states.pop(key, None)
62
+
63
+ def record_failure(self, key: str) -> None:
64
+ state = self._state(key)
65
+ state.failure_count += 1
66
+ if state.failure_count >= self.failure_threshold:
67
+ state.opened_at = time.monotonic()
tests/test_app.py CHANGED
@@ -14,6 +14,7 @@ import pytest
14
  from httpx import ASGITransport
15
 
16
  import app as app_module
 
17
  from scrap import PatentScrapResult
18
  from serp import SerpQuery
19
 
@@ -25,6 +26,16 @@ async def client():
25
  yield c
26
 
27
 
 
 
 
 
 
 
 
 
 
 
28
  # ------------------------- _shape_serp_results / _run_serp_queries -------------------------
29
 
30
 
@@ -238,3 +249,96 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
238
 
239
  assert launch_calls[0]["headless"] is True
240
  assert "--no-sandbox" in launch_calls[0].get("args", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  from httpx import ASGITransport
15
 
16
  import app as app_module
17
+ from circuit_breaker import CircuitBreaker
18
  from scrap import PatentScrapResult
19
  from serp import SerpQuery
20
 
 
26
  yield c
27
 
28
 
29
+ @pytest.fixture(autouse=True)
30
+ def _fresh_backend_circuit_breaker(monkeypatch):
31
+ """`_backend_circuit_breaker` is a module-level singleton shared across
32
+ every request in production, which is the point - but it means a
33
+ backend failure recorded by one test would otherwise leak into the
34
+ next. Give every test its own instance.
35
+ """
36
+ monkeypatch.setattr(app_module, "_backend_circuit_breaker", CircuitBreaker())
37
+
38
+
39
  # ------------------------- _shape_serp_results / _run_serp_queries -------------------------
40
 
41
 
 
249
 
250
  assert launch_calls[0]["headless"] is True
251
  assert "--no-sandbox" in launch_calls[0].get("args", [])
252
+
253
+
254
+ # --------------------------------- _search_one fallback ---------------------------------
255
+
256
+
257
+ async def test_search_one_returns_first_backend_that_succeeds(monkeypatch):
258
+ calls = []
259
+
260
+ async def fake_ddg(q, n):
261
+ calls.append("ddg")
262
+ return [{"title": "from ddg"}]
263
+
264
+ async def fake_brave(browser, q, n):
265
+ calls.append("brave")
266
+ return [{"title": "from brave"}]
267
+
268
+ monkeypatch.setattr(app_module, "query_ddg_search", fake_ddg)
269
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
270
+
271
+ q, results, err = await app_module._search_one("widget", 5)
272
+
273
+ assert calls == ["ddg"]
274
+ assert results == [{"title": "from ddg"}]
275
+ assert err is None
276
+
277
+
278
+ async def test_search_one_falls_through_to_the_next_backend_on_failure(monkeypatch):
279
+ calls = []
280
+
281
+ async def failing_ddg(q, n):
282
+ calls.append("ddg")
283
+ raise RuntimeError("ddg blocked")
284
+
285
+ async def fake_brave(browser, q, n):
286
+ calls.append("brave")
287
+ return [{"title": "from brave"}]
288
+
289
+ monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
290
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
291
+
292
+ q, results, err = await app_module._search_one("widget", 5)
293
+
294
+ assert calls == ["ddg", "brave"]
295
+ assert results == [{"title": "from brave"}]
296
+ assert err is None
297
+
298
+
299
+ async def test_search_one_reports_an_error_when_every_backend_fails(monkeypatch):
300
+ async def failing(*args):
301
+ raise RuntimeError("blocked")
302
+
303
+ monkeypatch.setattr(app_module, "query_ddg_search", failing)
304
+ monkeypatch.setattr(app_module, "query_brave_search", failing)
305
+ monkeypatch.setattr(app_module, "query_bing_search", failing)
306
+
307
+ q, results, err = await app_module._search_one("widget", 5)
308
+
309
+ assert results == []
310
+ assert "All backends failed for query 'widget'" in err
311
+ assert "blocked" in err
312
+
313
+
314
+ async def test_search_one_skips_a_backend_whose_circuit_is_open(monkeypatch):
315
+ """A backend that's failed `failure_threshold` times in a row shouldn't
316
+ be attempted again until its cooldown elapses - repeatedly hammering a
317
+ backend that's already rate-limiting us only makes that worse.
318
+ """
319
+ from circuit_breaker import CircuitBreaker
320
+
321
+ fresh_breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
322
+ monkeypatch.setattr(app_module, "_backend_circuit_breaker", fresh_breaker)
323
+
324
+ ddg_calls = []
325
+
326
+ async def failing_ddg(q, n):
327
+ ddg_calls.append(q)
328
+ raise RuntimeError("ddg blocked")
329
+
330
+ async def fake_brave(browser, q, n):
331
+ return [{"title": "from brave"}]
332
+
333
+ monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
334
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
335
+
336
+ # First call: DDG fails, opening its circuit (threshold=1); Brave picks it up.
337
+ await app_module._search_one("first query", 5)
338
+ assert ddg_calls == ["first query"]
339
+
340
+ # Second call: DDG's circuit is now open, so it should be skipped
341
+ # entirely (not called again) and go straight to Brave.
342
+ q, results, err = await app_module._search_one("second query", 5)
343
+ assert ddg_calls == ["first query"] # unchanged - DDG was skipped
344
+ assert results == [{"title": "from brave"}]
tests/test_circuit_breaker.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import circuit_breaker as cb_module
2
+ from circuit_breaker import CircuitBreaker, CircuitOpenError
3
+
4
+ import pytest
5
+
6
+
7
+ class _FakeClock:
8
+ """Deterministic stand-in for time.monotonic()."""
9
+
10
+ def __init__(self, start: float = 0.0):
11
+ self.now = start
12
+
13
+ def __call__(self) -> float:
14
+ return self.now
15
+
16
+ def advance(self, seconds: float) -> None:
17
+ self.now += seconds
18
+
19
+
20
+ @pytest.fixture
21
+ def clock(monkeypatch):
22
+ fake = _FakeClock()
23
+ monkeypatch.setattr(cb_module.time, "monotonic", fake)
24
+ return fake
25
+
26
+
27
+ def test_before_call_is_a_noop_below_the_failure_threshold(clock):
28
+ breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60)
29
+ breaker.record_failure("bing")
30
+ breaker.record_failure("bing")
31
+
32
+ breaker.before_call("bing") # should not raise - only 2 failures so far
33
+
34
+
35
+ def test_before_call_raises_once_threshold_is_reached(clock):
36
+ breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60)
37
+ for _ in range(3):
38
+ breaker.record_failure("bing")
39
+
40
+ with pytest.raises(CircuitOpenError):
41
+ breaker.before_call("bing")
42
+
43
+
44
+ def test_record_success_closes_the_circuit(clock):
45
+ breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60)
46
+ for _ in range(3):
47
+ breaker.record_failure("bing")
48
+
49
+ breaker.record_success("bing")
50
+
51
+ breaker.before_call("bing") # back to closed - should not raise
52
+
53
+
54
+ def test_before_call_allows_a_trial_after_the_cooldown_elapses(clock):
55
+ breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60)
56
+ for _ in range(3):
57
+ breaker.record_failure("bing")
58
+
59
+ clock.advance(61)
60
+
61
+ breaker.before_call("bing") # cooldown elapsed - half-open, should not raise
62
+
63
+
64
+ def test_a_failed_trial_after_cooldown_reopens_for_another_full_cooldown(clock):
65
+ breaker = CircuitBreaker(failure_threshold=3, cooldown_seconds=60)
66
+ for _ in range(3):
67
+ breaker.record_failure("bing")
68
+
69
+ clock.advance(61)
70
+ breaker.before_call("bing") # half-open trial begins
71
+ breaker.record_failure("bing") # ...and it failed
72
+
73
+ clock.advance(30) # only 30s into the new cooldown
74
+ with pytest.raises(CircuitOpenError):
75
+ breaker.before_call("bing")
76
+
77
+ clock.advance(31) # now past the new 60s cooldown
78
+ breaker.before_call("bing") # should not raise
79
+
80
+
81
+ def test_keys_are_independent(clock):
82
+ breaker = CircuitBreaker(failure_threshold=2, cooldown_seconds=60)
83
+ for _ in range(2):
84
+ breaker.record_failure("bing")
85
+
86
+ breaker.before_call("brave") # untouched key - should not raise
87
+ with pytest.raises(CircuitOpenError):
88
+ breaker.before_call("bing")
89
+
90
+
91
+ def test_circuit_open_error_carries_the_key_and_remaining_seconds(clock):
92
+ breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
93
+ breaker.record_failure("bing")
94
+
95
+ clock.advance(10)
96
+ with pytest.raises(CircuitOpenError) as exc_info:
97
+ breaker.before_call("bing")
98
+
99
+ assert exc_info.value.key == "bing"
100
+ assert exc_info.value.retry_after == pytest.approx(50, abs=0.01)