Almaatla commited on
Commit
33657f8
·
unverified ·
2 Parent(s): 5e51b32ba2278a

Address remaining hardening items from the TDD/architecture review (#4)

Browse files
Files changed (6) hide show
  1. Dockerfile +9 -2
  2. app.py +39 -9
  3. circuit_breaker.py +67 -0
  4. serp.py +8 -2
  5. tests/test_app.py +185 -0
  6. tests/test_circuit_breaker.py +100 -0
Dockerfile CHANGED
@@ -7,12 +7,19 @@ RUN pip install --no-cache-dir -r requirements.txt
7
 
8
  ENV PLAYWRIGHT_BROWSERS_PATH=0
9
 
10
- RUN pip install --no-cache-dir playwright && \
11
- playwright install-deps chromium && \
12
  playwright install chromium
13
 
14
  COPY . .
15
 
 
 
 
 
 
 
 
 
16
  EXPOSE 7860
17
 
18
  CMD ["python", "./app.py"]
 
7
 
8
  ENV PLAYWRIGHT_BROWSERS_PATH=0
9
 
10
+ RUN playwright install-deps chromium && \
 
11
  playwright install chromium
12
 
13
  COPY . .
14
 
15
+ # Hugging Face Spaces' documented convention for custom Docker Spaces: a
16
+ # non-root user with a fixed UID
17
+ # (https://huggingface.co/docs/hub/spaces-sdks-docker#permissions). Chromium
18
+ # is launched with --no-sandbox (see api_lifespan in app.py) since its own
19
+ # internal sandbox needs privileges this non-root user doesn't have.
20
+ RUN useradd -m -u 1000 user && chown -R user:user /app
21
+ USER user
22
+
23
  EXPOSE 7860
24
 
25
  CMD ["python", "./app.py"]
app.py CHANGED
@@ -3,22 +3,29 @@ import logging
3
  import os
4
  import secrets
5
  from contextlib import asynccontextmanager
6
- from typing import Optional
7
  from fastapi import APIRouter, FastAPI, HTTPException, Request
8
  from fastapi.responses import JSONResponse
9
  from fastapi.routing import APIRouter
10
  import httpx
11
  from httpx import HTTPStatusError
12
- from pydantic import BaseModel, Field
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 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
19
  from utils import log_gathered_exceptions
20
  from mcp_server import mount_mcp_server
21
 
 
 
 
 
 
 
22
  logging.basicConfig(
23
  level=logging.INFO,
24
  format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
@@ -67,7 +74,12 @@ async def api_lifespan(app: FastAPI):
67
  global playwright, pw_browser
68
  try:
69
  playwright = await async_playwright().start()
70
- pw_browser = await playwright.chromium.launch(headless=True)
 
 
 
 
 
71
  logging.info("Playwright browser started.")
72
  except Exception as e:
73
  logging.warning(f"Playwright unavailable, browser-based endpoints will fail: {e}")
@@ -195,6 +207,14 @@ async def search_duck(params: SerpQuery) -> SerpResults:
195
  lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
196
 
197
 
 
 
 
 
 
 
 
 
198
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
199
  """Try DDG, then Brave, then Bing for a single query; stop at the first success."""
200
  backends = [
@@ -204,11 +224,21 @@ async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional
204
  ]
205
  last_error: Optional[Exception] = None
206
  for name, call in backends:
 
 
 
 
 
 
 
207
  try:
208
  logging.info(f"Querying {name} with query: `{q}`")
209
- return q, await call(), None
 
 
210
  except Exception as e:
211
  logging.error(f"Failed to query {name} with query `{q}`: {e}")
 
212
  last_error = e
213
 
214
  return q, [], f"All backends failed for query '{q}': {last_error}"
@@ -235,7 +265,7 @@ async def search(params: SerpQuery):
235
 
236
 
237
  @scrap_router.get("/scrap_patent/{patent_id}")
238
- async def scrap_patent(patent_id: str) -> PatentScrapResult:
239
  """Scraps the specified patent from Google Patents.
240
 
241
  Falls back to the EPO OPS API (which covers patents missing from Google
@@ -274,8 +304,8 @@ async def scrap_patent(patent_id: str) -> PatentScrapResult:
274
 
275
  class ScrapPatentsRequest(BaseModel):
276
  """Request model for scrapping multiple patents."""
277
- patent_ids: list[str] = Field(...,
278
- description="List of patent IDs to scrap")
279
 
280
 
281
  @scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
@@ -296,7 +326,7 @@ async def ops_keyword_search(params: SerpQuery) -> SerpResults:
296
 
297
 
298
  @ops_router.get("/scrap_patent/{patent_id}")
299
- async def ops_get_patent(patent_id: str) -> PatentScrapResult:
300
  """Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
301
  if not ops_token_manager.configured:
302
  raise HTTPException(
 
3
  import os
4
  import secrets
5
  from contextlib import asynccontextmanager
6
+ from typing import Annotated, Optional
7
  from fastapi import APIRouter, FastAPI, HTTPException, Request
8
  from fastapi.responses import JSONResponse
9
  from fastapi.routing import APIRouter
10
  import httpx
11
  from httpx import HTTPStatusError
12
+ from pydantic import BaseModel, Field, StringConstraints
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
20
  from utils import log_gathered_exceptions
21
  from mcp_server import mount_mcp_server
22
 
23
+ # Anchored version of serp.py's PATENT_ID_CORE: validates a whole
24
+ # user-supplied patent id rather than finding one inside free text. Rejects
25
+ # obviously-malformed input (e.g. containing "/" or "?") with a clean 422
26
+ # instead of it reaching Google Patents/OPS as a confusing request.
27
+ PatentId = Annotated[str, StringConstraints(pattern=rf"^{PATENT_ID_CORE}$")]
28
+
29
  logging.basicConfig(
30
  level=logging.INFO,
31
  format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d]: %(message)s',
 
74
  global playwright, pw_browser
75
  try:
76
  playwright = await async_playwright().start()
77
+ # --no-sandbox: the container runs as a non-root user (see the
78
+ # Dockerfile), and Chromium's own internal sandbox needs privileges
79
+ # a non-root container user doesn't have - without this it fails to
80
+ # launch at all. The container itself is still the isolation
81
+ # boundary against a compromised renderer.
82
+ pw_browser = await playwright.chromium.launch(headless=True, args=["--no-sandbox"])
83
  logging.info("Playwright browser started.")
84
  except Exception as e:
85
  logging.warning(f"Playwright unavailable, browser-based endpoints will fail: {e}")
 
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}"
 
265
 
266
 
267
  @scrap_router.get("/scrap_patent/{patent_id}")
268
+ async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
269
  """Scraps the specified patent from Google Patents.
270
 
271
  Falls back to the EPO OPS API (which covers patents missing from Google
 
304
 
305
  class ScrapPatentsRequest(BaseModel):
306
  """Request model for scrapping multiple patents."""
307
+ patent_ids: list[PatentId] = Field(...,
308
+ description="List of patent IDs to scrap")
309
 
310
 
311
  @scrap_router.post("/scrap_patents_bulk", response_model=PatentScrapBulkResponse)
 
326
 
327
 
328
  @ops_router.get("/scrap_patent/{patent_id}")
329
+ async def ops_get_patent(patent_id: PatentId) -> PatentScrapResult:
330
  """Retrieves a patent (biblio + abstract + claims + description) via EPO OPS."""
331
  if not ops_token_manager.configured:
332
  raise HTTPException(
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()
serp.py CHANGED
@@ -124,8 +124,14 @@ async def query_google_scholar(browser: Browser, q: str, n_results: int = 10):
124
  return await _extract_google_scholar_results(page, n_results)
125
 
126
 
127
- # regex to locate a patent id, e.g. "US11930446B2" or "EP4760514A1"
128
- PATENT_ID_REGEX = r"\b[A-Z]{2}\d{6,}(?:[A-Z]\d?)?\b"
 
 
 
 
 
 
129
 
130
 
131
  async def _extract_google_patents_results(page: Page, n_results: int) -> list[dict]:
 
124
  return await _extract_google_scholar_results(page, n_results)
125
 
126
 
127
+ # A patent id's shape, e.g. "US11930446B2" or "EP4760514A1": a two-letter
128
+ # country code, at least 6 digits, and an optional kind-code suffix (a
129
+ # letter plus 0-2 digits). PATENT_ID_REGEX finds one inside free text (used
130
+ # to pull ids out of scraped search results); app.py reuses the same core
131
+ # pattern, anchored, to validate a user-supplied patent id - one definition
132
+ # so the two can't drift apart.
133
+ PATENT_ID_CORE = r"[A-Z]{2}\d{6,}(?:[A-Z]\d?)?"
134
+ PATENT_ID_REGEX = rf"\b{PATENT_ID_CORE}\b"
135
 
136
 
137
  async def _extract_google_patents_results(page: Page, n_results: int) -> list[dict]:
tests/test_app.py CHANGED
@@ -14,6 +14,8 @@ import pytest
14
  from httpx import ASGITransport
15
 
16
  import app as app_module
 
 
17
  from serp import SerpQuery
18
 
19
 
@@ -24,6 +26,16 @@ async def client():
24
  yield c
25
 
26
 
 
 
 
 
 
 
 
 
 
 
27
  # ------------------------- _shape_serp_results / _run_serp_queries -------------------------
28
 
29
 
@@ -146,6 +158,42 @@ async def test_search_patents_no_fallback_when_ops_not_configured(client, monkey
146
  assert data["error"] is None
147
 
148
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  async def test_ops_keyword_search_flattens_results(client, monkeypatch):
150
  async def fake_ops_search(client_arg, q, n):
151
  return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
@@ -157,3 +205,140 @@ async def test_ops_keyword_search_flattens_results(client, monkeypatch):
157
  data = resp.json()
158
  assert data["error"] is None
159
  assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
21
 
 
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
 
 
158
  assert data["error"] is None
159
 
160
 
161
+ # ------------------------------- patent_id validation -------------------------------
162
+
163
+
164
+ async def test_scrap_patent_rejects_malformed_patent_id(client):
165
+ resp = await client.get("/scrap/scrap_patent/not-a-patent-id")
166
+ assert resp.status_code == 422
167
+
168
+
169
+ async def test_scrap_patent_accepts_well_formed_patent_id(client, monkeypatch):
170
+ async def fake_scrap(client_arg, url):
171
+ return PatentScrapResult(title="Widget apparatus")
172
+
173
+ monkeypatch.setattr(app_module, "scrap_patent_async", fake_scrap)
174
+
175
+ resp = await client.get("/scrap/scrap_patent/US11930446B2")
176
+
177
+ assert resp.status_code == 200
178
+ assert resp.json()["title"] == "Widget apparatus"
179
+
180
+
181
+ async def test_ops_get_patent_rejects_malformed_patent_id(client):
182
+ resp = await client.get("/ops/scrap_patent/not-a-patent-id")
183
+ assert resp.status_code == 422
184
+
185
+
186
+ async def test_scrap_patents_bulk_rejects_malformed_id_in_list(client):
187
+ resp = await client.post(
188
+ "/scrap/scrap_patents_bulk", json={"patent_ids": ["US11930446B2", "garbage!!"]})
189
+ assert resp.status_code == 422
190
+
191
+
192
+ async def test_ops_scrap_patents_bulk_rejects_malformed_id_in_list(client):
193
+ resp = await client.post("/ops/scrap_patents_bulk", json={"patent_ids": ["garbage!!"]})
194
+ assert resp.status_code == 422
195
+
196
+
197
  async def test_ops_keyword_search_flattens_results(client, monkeypatch):
198
  async def fake_ops_search(client_arg, q, n):
199
  return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
 
205
  data = resp.json()
206
  assert data["error"] is None
207
  assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}
208
+
209
+
210
+ # ------------------------------------- api_lifespan -------------------------------------
211
+
212
+
213
+ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
214
+ """The container runs as a non-root user (see the Dockerfile), and
215
+ Chromium's own internal sandbox needs privileges a non-root container
216
+ user doesn't have - so it must be launched with --no-sandbox, or it
217
+ fails to start at all in that environment.
218
+ """
219
+ launch_calls = []
220
+
221
+ class FakeBrowser:
222
+ async def close(self):
223
+ pass
224
+
225
+ class FakeChromium:
226
+ async def launch(self, **kwargs):
227
+ launch_calls.append(kwargs)
228
+ return FakeBrowser()
229
+
230
+ class FakePlaywright:
231
+ chromium = FakeChromium()
232
+
233
+ async def stop(self):
234
+ pass
235
+
236
+ class FakePlaywrightContextManager:
237
+ async def start(self):
238
+ return FakePlaywright()
239
+
240
+ # Reset via monkeypatch (not a plain assignment) so its automatic
241
+ # teardown restores whatever these module globals held before this test,
242
+ # regardless of what api_lifespan reassigns them to while it runs.
243
+ monkeypatch.setattr(app_module, "pw_browser", None)
244
+ monkeypatch.setattr(app_module, "playwright", None)
245
+ monkeypatch.setattr(app_module, "async_playwright", lambda: FakePlaywrightContextManager())
246
+
247
+ async with app_module.api_lifespan(app_module.app):
248
+ pass
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)