Claude Claude Opus 5 commited on
Commit
9841346
·
unverified ·
1 Parent(s): 060118a

Housekeeping: lint in CI, client lifecycle, and a truer breaker signal

Browse files

Independent cleanups from the review, grouped because each is small.

A3 - utils.log_gathered_exceptions imported SerpQuery from serp.py purely
to read one attribute, so the lowest layer in the codebase depended on the
domain layer. It now takes any iterable of query strings, which also makes
it usable from the patent and OPS paths.

A4 - the shared httpx client was created at import and never closed; the
lifespan tore down the browser but left its connection pool open. It is
now closed on shutdown, including when Playwright failed to start. Two
lifespan tests cover this, and the existing one no longer closes the real
client out from under the rest of the session.

A5 - the circuit breaker could not tell "this query had no hits" from
"this backend is refusing us". query_ddg_search raises on an empty result
because a soft block can parse to zero results, but _search_one counted
every exception against the backend - and the breaker is a process-wide
singleton, so three unusual-but-legitimate queries in a row disabled the
primary backend for every user for a full cooldown. Empty results now get
their own exception type: still falls through to the next backend, no
longer counted as evidence the backend is unhealthy. A hard block normally
raises a real error, which still trips it.

A6 - the OPS fallback in search_patents awaited one query at a time while
the rest of the endpoint gathered. That 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. Gathered now.

A7 - ruff wired into both workflows with a deliberately small rule set.
Its first pass found the duplicate APIRouter import shadowing the other,
four unused imports, an f-string with no placeholders, seven exception
re-raises losing their cause chain, and two ambiguous zips - all fixed
rather than ignored. Also: playwright_open_page now closes its context
even when closing the page raises, instead of leaking it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e

.github/workflows/deploy-to-hf.yml CHANGED
@@ -24,6 +24,9 @@ jobs:
24
  - name: Install Playwright's Chromium
25
  run: playwright install --with-deps chromium
26
 
 
 
 
27
  - name: Run tests
28
  run: pytest
29
 
 
24
  - name: Install Playwright's Chromium
25
  run: playwright install --with-deps chromium
26
 
27
+ - name: Lint
28
+ run: ruff check .
29
+
30
  - name: Run tests
31
  run: pytest
32
 
.github/workflows/test.yml CHANGED
@@ -29,5 +29,8 @@ jobs:
29
  - name: Install Playwright's Chromium
30
  run: playwright install --with-deps chromium
31
 
 
 
 
32
  - name: Run tests
33
  run: pytest
 
29
  - name: Install Playwright's Chromium
30
  run: playwright install --with-deps chromium
31
 
32
+ - name: Lint
33
+ run: ruff check .
34
+
35
  - name: Run tests
36
  run: pytest
CONTRIBUTING.md CHANGED
@@ -26,10 +26,11 @@ dependency update broke this."
26
  ## Running tests
27
 
28
  ```bash
 
29
  pytest
30
  ```
31
 
32
- 97 tests, a few seconds, fully offline. All outbound HTTP is mocked with
33
  `respx`; the Playwright-driven scrapers (Bing, Brave, Google Scholar,
34
  Google Patents search) run against a real headless Chromium but navigate to
35
  local fixture HTML instead of the live sites — see `tests/helpers.py` for
@@ -77,6 +78,22 @@ changes should too:
77
  confirm the test actually catches it, then revert. This caught a real
78
  gap once already: a circuit breaker test that looked correct was
79
  actually vacuous until this step exposed it.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  6. **Watch for shared mutable state across tests.** A few characterization
81
  tests in this codebase turned out to be recording real state into a
82
  module-level singleton (a circuit breaker) with nothing resetting it
@@ -93,8 +110,13 @@ changes should too:
93
 
94
  ## CI / deployment
95
 
96
- - **`.github/workflows/test.yml`** runs the full suite on every pull
97
- request against `main`, so a change gets feedback before merge.
 
 
 
 
 
98
  - **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
99
  (`test` job) on every push to `main`, and only then (`needs: test`)
100
  pushes the repo to the Hugging Face Space (`deploy` job) with
 
26
  ## Running tests
27
 
28
  ```bash
29
+ ruff check . # same lint CI runs
30
  pytest
31
  ```
32
 
33
+ 199 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
 
78
  confirm the test actually catches it, then revert. This caught a real
79
  gap once already: a circuit breaker test that looked correct was
80
  actually vacuous until this step exposed it.
81
+
82
+ Applying this systematically is worth a pass of its own, and one such
83
+ pass is where most of the tests added since came from. Line coverage
84
+ was 93% and hid the problem completely: the suite killed five of six
85
+ mutants aimed at pure functions, and one of seven aimed at the I/O
86
+ boundary. Deleting the entire API-key middleware left it green, as did
87
+ replacing the Bing search URL with a constant. The lesson worth keeping
88
+ is *where* to point this technique - at the code that touches the
89
+ outside world, not the code that's easy to test.
90
+
91
+ A useful heuristic fell out of it: if a property has no behavioural
92
+ signature at all, no black-box test can pin it. `==` and
93
+ `secrets.compare_digest` return the same answer for every input and
94
+ differ only in timing, so that one test asserts structurally that the
95
+ comparison goes through `compare_digest`. Reach for that only when
96
+ there is genuinely nothing observable to assert on.
97
  6. **Watch for shared mutable state across tests.** A few characterization
98
  tests in this codebase turned out to be recording real state into a
99
  module-level singleton (a circuit breaker) with nothing resetting it
 
110
 
111
  ## CI / deployment
112
 
113
+ - **`.github/workflows/test.yml`** runs `ruff check .` and then the full
114
+ suite on every pull request against `main`, so a change gets feedback
115
+ before merge. The lint rule set (see `pyproject.toml`) is deliberately
116
+ small - pyflakes-equivalent checks plus bugbear - because the first pass
117
+ over this repo found a duplicate import shadowing another, four unused
118
+ imports and an f-string with no placeholders. Widen it when the team
119
+ wants more rather than starting broad and accumulating ignores.
120
  - **`.github/workflows/deploy-to-hf.yml`** runs the same suite again
121
  (`test` job) on every push to `main`, and only then (`needs: test`)
122
  pushes the repo to the Hugging Face Space (`deploy` job) with
README.md CHANGED
@@ -19,13 +19,15 @@ pinned: true
19
  ```
20
  pip install -r requirements.txt -r requirements-dev.txt
21
  playwright install chromium # if not already present on the machine
 
22
  pytest
23
  ```
24
 
25
  The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
26
- scrapers against local fixture HTML instead of live search engines - see
27
- `tests/helpers.py` for how - so it runs fully offline and doesn't depend on,
28
- or risk tripping, any site's anti-bot measures.
 
29
 
30
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development methodology
31
  (TDD workflow, dependency pinning policy, CI/deployment) behind this project.
 
19
  ```
20
  pip install -r requirements.txt -r requirements-dev.txt
21
  playwright install chromium # if not already present on the machine
22
+ ruff check .
23
  pytest
24
  ```
25
 
26
  The suite mocks all outbound HTTP (via `respx`) and drives the Playwright-based
27
+ scrapers against local fixture HTML loaded straight into a real headless
28
+ Chromium - see the `page_factory` fixture in `tests/conftest.py` - so it runs
29
+ fully offline and doesn't depend on, or risk tripping, any site's anti-bot
30
+ measures.
31
 
32
  See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development methodology
33
  (TDD workflow, dependency pinning policy, CI/deployment) behind this project.
app.py CHANGED
@@ -4,18 +4,18 @@ 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
@@ -36,7 +36,9 @@ logging.basicConfig(
36
  playwright = None
37
  pw_browser: Optional[Browser] = None
38
 
39
- # httpx client
 
 
40
  httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
41
  max_connections=30, max_keepalive_connections=20))
42
 
@@ -89,6 +91,7 @@ async def api_lifespan(app: FastAPI):
89
  await pw_browser.close()
90
  if playwright:
91
  await playwright.stop()
 
92
 
93
  app = FastAPI(lifespan=api_lifespan, docs_url="/",
94
  title="SERPent", description=_load_docs())
@@ -149,7 +152,7 @@ async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
149
  """
150
  results = await asyncio.gather(
151
  *[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
152
- log_gathered_exceptions(results, context, params)
153
  return _shape_serp_results(results)
154
 
155
 
@@ -178,18 +181,30 @@ async def search_patents(params: SerpQuery) -> SerpResults:
178
  """
179
  logging.info(f"Searching Google Patents for queries: {params.queries}")
180
  results = await asyncio.gather(*[query_google_patents(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
181
- log_gathered_exceptions(results, "google patent search", params)
182
 
183
  # Fall back to OPS for queries that errored or returned no results.
 
 
 
184
  if ops_token_manager.configured:
185
- for i, res in enumerate(results):
186
- if isinstance(res, Exception) or not res:
187
- try:
188
- logging.info(
189
- f"Google Patents empty for `{params.queries[i]}`, trying OPS.")
190
- results[i] = await ops_search(httpx_client, params.queries[i], params.n_results)
191
- except Exception as e:
192
- logging.warning(f"OPS fallback failed: {e}")
 
 
 
 
 
 
 
 
 
193
 
194
  return _shape_serp_results(results)
195
 
@@ -247,6 +262,15 @@ async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional
247
  result = await call()
248
  _backend_circuit_breaker.record_success(name)
249
  return q, result, None
 
 
 
 
 
 
 
 
 
250
  except Exception as e:
251
  logging.error(f"Failed to query {name} with query `{q}`: {e}")
252
  _backend_circuit_breaker.record_failure(name)
@@ -331,14 +355,14 @@ async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
331
  if _is_not_found(google_error) and _is_not_found(e):
332
  raise HTTPException(
333
  status_code=404,
334
- detail=f"Patent '{patent_id}' not found on Google Patents or EPO OPS.")
335
  if isinstance(e, HTTPStatusError):
336
  raise HTTPException(
337
  status_code=_upstream_status(e),
338
- detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
339
  raise HTTPException(
340
  status_code=_upstream_status(e),
341
- detail=f"EPO OPS request failed for '{patent_id}': {e}")
342
 
343
 
344
  # Same reasoning as MAX_QUERIES_PER_REQUEST in serp.py: one accepted
@@ -381,20 +405,20 @@ async def ops_get_patent(patent_id: PatentId) -> PatentScrapResult:
381
  detail="EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
382
  try:
383
  return await ops_scrap_patent(httpx_client, patent_id)
384
- except OPSNotConfigured:
385
- raise HTTPException(status_code=503, detail="EPO OPS is not configured.")
386
  except HTTPStatusError as e:
387
  if e.response.status_code == 404:
388
  raise HTTPException(
389
- status_code=404, detail=f"Patent '{patent_id}' not found in EPO OPS.")
390
  raise HTTPException(
391
  status_code=_upstream_status(e),
392
- detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
393
  except Exception as e:
394
  logging.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
395
  raise HTTPException(
396
  status_code=_upstream_status(e),
397
- detail=f"EPO OPS request failed for '{patent_id}': {e}")
398
 
399
 
400
  @ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
 
4
  import secrets
5
  from contextlib import asynccontextmanager
6
  from typing import Annotated, Optional
7
+ from fastapi import 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
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 EmptyResultsError, 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
 
36
  playwright = None
37
  pw_browser: Optional[Browser] = None
38
 
39
+ # httpx client. Constructed at import so module-level handlers can close
40
+ # over it, but its lifetime is owned by `api_lifespan`, which closes it on
41
+ # shutdown alongside the browser.
42
  httpx_client = httpx.AsyncClient(timeout=30, limits=httpx.Limits(
43
  max_connections=30, max_keepalive_connections=20))
44
 
 
91
  await pw_browser.close()
92
  if playwright:
93
  await playwright.stop()
94
+ await httpx_client.aclose()
95
 
96
  app = FastAPI(lifespan=api_lifespan, docs_url="/",
97
  title="SERPent", description=_load_docs())
 
152
  """
153
  results = await asyncio.gather(
154
  *[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
155
+ log_gathered_exceptions(results, context, params.queries)
156
  return _shape_serp_results(results)
157
 
158
 
 
181
  """
182
  logging.info(f"Searching Google Patents for queries: {params.queries}")
183
  results = await asyncio.gather(*[query_google_patents(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
184
+ log_gathered_exceptions(results, "google patent search", params.queries)
185
 
186
  # Fall back to OPS for queries that errored or returned no results.
187
+ # Gathered rather than awaited one at a time: this path exists for when
188
+ # Google Patents is unavailable, so it is exactly when a request is
189
+ # least able to afford N sequential round-trips to the slower backend.
190
  if ops_token_manager.configured:
191
+ needs_fallback = [
192
+ i for i, res in enumerate(results)
193
+ if isinstance(res, Exception) or not res]
194
+ if needs_fallback:
195
+ logging.info(
196
+ f"Google Patents empty for {len(needs_fallback)} quer(y/ies), trying OPS.")
197
+ fallback_results = await asyncio.gather(
198
+ *[ops_search(httpx_client, params.queries[i], params.n_results)
199
+ for i in needs_fallback],
200
+ return_exceptions=True)
201
+ # strict: gather returns exactly one result per index, so a
202
+ # length mismatch here would be a bug worth surfacing.
203
+ for i, res in zip(needs_fallback, fallback_results, strict=True):
204
+ if isinstance(res, Exception):
205
+ logging.warning(f"OPS fallback failed for `{params.queries[i]}`: {res}")
206
+ else:
207
+ results[i] = res
208
 
209
  return _shape_serp_results(results)
210
 
 
262
  result = await call()
263
  _backend_circuit_breaker.record_success(name)
264
  return q, result, None
265
+ except EmptyResultsError as e:
266
+ # Zero results is ambiguous - no hits, or a soft block that
267
+ # parsed to nothing. Move on to the next backend, but don't
268
+ # hold it against this one: the breaker is process-wide, so
269
+ # counting it would let a few unusual-but-legitimate queries
270
+ # disable the primary backend for every user. A hard block
271
+ # normally raises a real error, which is handled below.
272
+ logging.info(f"{name} returned no results for query `{q}`")
273
+ last_error = e
274
  except Exception as e:
275
  logging.error(f"Failed to query {name} with query `{q}`: {e}")
276
  _backend_circuit_breaker.record_failure(name)
 
355
  if _is_not_found(google_error) and _is_not_found(e):
356
  raise HTTPException(
357
  status_code=404,
358
+ detail=f"Patent '{patent_id}' not found on Google Patents or EPO OPS.") from e
359
  if isinstance(e, HTTPStatusError):
360
  raise HTTPException(
361
  status_code=_upstream_status(e),
362
+ detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.") from e
363
  raise HTTPException(
364
  status_code=_upstream_status(e),
365
+ detail=f"EPO OPS request failed for '{patent_id}': {e}") from e
366
 
367
 
368
  # Same reasoning as MAX_QUERIES_PER_REQUEST in serp.py: one accepted
 
405
  detail="EPO OPS is not configured (OPS_CONSUMER_KEY / OPS_CONSUMER_SECRET missing).")
406
  try:
407
  return await ops_scrap_patent(httpx_client, patent_id)
408
+ except OPSNotConfigured as e:
409
+ raise HTTPException(status_code=503, detail="EPO OPS is not configured.") from e
410
  except HTTPStatusError as e:
411
  if e.response.status_code == 404:
412
  raise HTTPException(
413
+ status_code=404, detail=f"Patent '{patent_id}' not found in EPO OPS.") from e
414
  raise HTTPException(
415
  status_code=_upstream_status(e),
416
+ detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.") from e
417
  except Exception as e:
418
  logging.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
419
  raise HTTPException(
420
  status_code=_upstream_status(e),
421
+ detail=f"EPO OPS request failed for '{patent_id}': {e}") from e
422
 
423
 
424
  @ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
pyproject.toml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lint configuration. The rule set is deliberately small: this repo had no
2
+ # linter, and the first pass found a duplicate import shadowing another, four
3
+ # unused imports and an f-string with no placeholders - all of which a
4
+ # default pyflakes-equivalent catches. Widen the selection when the team
5
+ # wants more, rather than starting from a large set and adding ignores.
6
+ [tool.ruff]
7
+ line-length = 120
8
+
9
+ [tool.ruff.lint]
10
+ select = [
11
+ "F", # pyflakes: unused/duplicate imports, f-strings without placeholders
12
+ "E9", # syntax and IO errors
13
+ "B", # bugbear: likely-wrong constructs
14
+ ]
15
+ ignore = [
16
+ "B008", # FastAPI's Depends()/Field() in defaults is the documented idiom
17
+ ]
requirements-dev.txt CHANGED
@@ -10,3 +10,5 @@
10
  pytest>=8,<10
11
  pytest-asyncio>=1.4.0,<1.5
12
  respx>=0.23.1,<0.24
 
 
 
10
  pytest>=8,<10
11
  pytest-asyncio>=1.4.0,<1.5
12
  respx>=0.23.1,<0.24
13
+ # Lint. Pinned like everything else; `ruff check .` is what CI runs.
14
+ ruff>=0.14.6,<0.15
scrap.py CHANGED
@@ -1,5 +1,4 @@
1
  import asyncio
2
- import logging
3
  import re
4
  from typing import Optional
5
  from httpx import AsyncClient
 
1
  import asyncio
 
2
  import re
3
  from typing import Optional
4
  from httpx import AsyncClient
serp.py CHANGED
@@ -3,9 +3,8 @@ from typing import Optional
3
  from duckduckgo_search import DDGS
4
  import httpx
5
  from pydantic import BaseModel, Field, field_validator
6
- from playwright.async_api import Browser, BrowserContext, Page, TimeoutError
7
  from urllib.parse import quote_plus
8
- import logging
9
  import re
10
  from lxml import etree
11
  import asyncio
@@ -61,14 +60,26 @@ class BrowserUnavailableError(Exception):
61
  super().__init__("Playwright browser is not available (it failed to start at startup).")
62
 
63
 
64
- class DuckDuckGoBlockedException(Exception):
 
 
 
 
 
 
 
 
 
 
 
 
65
  """Raised when DuckDuckGo returns zero results.
66
 
67
  The duckduckgo_search library doesn't always raise when its request is
68
  blocked/rate-limited — it can also just parse zero results from the
69
- anti-bot response page and return an empty list. Since callers (the
70
- `/serp/search` fallback chain in particular) rely on an exception to know
71
- a backend failed, treat an empty result as a failure too.
72
  """
73
 
74
  def __init__(self, *args):
@@ -89,8 +100,12 @@ async def playwright_open_page(browser: Optional[Browser]):
89
  try:
90
  yield page
91
  finally:
92
- await page.close()
93
- await context.close()
 
 
 
 
94
 
95
 
96
  async def _block_stylesheet_and_image_resources(route, request):
@@ -158,7 +173,7 @@ async def _extract_google_patents_results(page: Page, n_results: int) -> list[di
158
  # Wait for at least one search result item to appear
159
  # This ensures the page has loaded enough to start scraping
160
  await page.wait_for_function(
161
- f"""() => document.querySelectorAll('search-result-item').length >= 1""",
162
  timeout=30_000
163
  )
164
 
 
3
  from duckduckgo_search import DDGS
4
  import httpx
5
  from pydantic import BaseModel, Field, field_validator
6
+ from playwright.async_api import Browser, BrowserContext, Page
7
  from urllib.parse import quote_plus
 
8
  import re
9
  from lxml import etree
10
  import asyncio
 
60
  super().__init__("Playwright browser is not available (it failed to start at startup).")
61
 
62
 
63
+ class EmptyResultsError(Exception):
64
+ """A backend returned zero results.
65
+
66
+ Deliberately distinct from a backend erroring, because zero results is
67
+ ambiguous: it can mean the query genuinely has no hits, or that the
68
+ backend served an anti-bot page that parsed to nothing. Callers (the
69
+ `/serp/search` fallback chain in particular) rely on an exception to
70
+ move on to the next backend, but should not treat this one as evidence
71
+ that the backend itself is unhealthy.
72
+ """
73
+
74
+
75
+ class DuckDuckGoBlockedException(EmptyResultsError):
76
  """Raised when DuckDuckGo returns zero results.
77
 
78
  The duckduckgo_search library doesn't always raise when its request is
79
  blocked/rate-limited — it can also just parse zero results from the
80
+ anti-bot response page and return an empty list. A hard block usually
81
+ does raise (and that still counts against the backend); this covers the
82
+ soft case, which is indistinguishable from a genuinely empty search.
83
  """
84
 
85
  def __init__(self, *args):
 
100
  try:
101
  yield page
102
  finally:
103
+ # Close the context even if closing the page fails (a crashed
104
+ # renderer), otherwise the context leaks for the process's life.
105
+ try:
106
+ await page.close()
107
+ finally:
108
+ await context.close()
109
 
110
 
111
  async def _block_stylesheet_and_image_resources(route, request):
 
173
  # Wait for at least one search result item to appear
174
  # This ensures the page has loaded enough to start scraping
175
  await page.wait_for_function(
176
+ "() => document.querySelectorAll('search-result-item').length >= 1",
177
  timeout=30_000
178
  )
179
 
tests/test_app.py CHANGED
@@ -243,6 +243,10 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
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
@@ -251,6 +255,54 @@ async def test_api_lifespan_launches_chromium_without_a_sandbox(monkeypatch):
251
  assert "--no-sandbox" in launch_calls[0].get("args", [])
252
 
253
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
254
  # --------------------------------- _search_one fallback ---------------------------------
255
 
256
 
@@ -419,3 +471,87 @@ async def test_search_endpoint_falls_through_backends_per_query(client, monkeypa
419
  data = resp.json()
420
  assert data["results"] == [{"title": "brave has a"}]
421
  assert data["error"] is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # The lifespan also closes the shared httpx client on shutdown; swap in
247
+ # a stand-in so this test doesn't close the real one for the rest of
248
+ # the session.
249
+ monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
250
 
251
  async with app_module.api_lifespan(app_module.app):
252
  pass
 
255
  assert "--no-sandbox" in launch_calls[0].get("args", [])
256
 
257
 
258
+ class _RecordingClient:
259
+ def __init__(self):
260
+ self.closed = False
261
+
262
+ async def aclose(self):
263
+ self.closed = True
264
+
265
+
266
+ async def test_api_lifespan_closes_the_http_client_on_shutdown(monkeypatch):
267
+ """The client was created at import and never closed - the browser was
268
+ torn down on shutdown but its connection pool was not.
269
+ """
270
+ class FakePlaywright:
271
+ chromium = None
272
+
273
+ async def stop(self):
274
+ pass
275
+
276
+ recording_client = _RecordingClient()
277
+ monkeypatch.setattr(app_module, "pw_browser", None)
278
+ monkeypatch.setattr(app_module, "playwright", None)
279
+ monkeypatch.setattr(app_module, "httpx_client", recording_client)
280
+ # Playwright failing to start is the documented degraded mode; the
281
+ # client must still be closed cleanly on the way out.
282
+ monkeypatch.setattr(
283
+ app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))
284
+
285
+ async with app_module.api_lifespan(app_module.app):
286
+ pass
287
+
288
+ assert recording_client.closed is True
289
+
290
+
291
+ async def test_api_lifespan_survives_playwright_failing_to_start(monkeypatch):
292
+ """Documented behaviour: the app still comes up, pw_browser stays None,
293
+ and the browser-backed endpoints fail individually rather than the whole
294
+ deployment refusing to boot.
295
+ """
296
+ monkeypatch.setattr(app_module, "pw_browser", None)
297
+ monkeypatch.setattr(app_module, "playwright", None)
298
+ monkeypatch.setattr(app_module, "httpx_client", _RecordingClient())
299
+ monkeypatch.setattr(
300
+ app_module, "async_playwright", lambda: (_ for _ in ()).throw(RuntimeError("no browser")))
301
+
302
+ async with app_module.api_lifespan(app_module.app):
303
+ assert app_module.pw_browser is None
304
+
305
+
306
  # --------------------------------- _search_one fallback ---------------------------------
307
 
308
 
 
471
  data = resp.json()
472
  assert data["results"] == [{"title": "brave has a"}]
473
  assert data["error"] is None
474
+
475
+
476
+ # --------------------- what counts as a backend failure ---------------------
477
+
478
+
479
+ async def test_a_zero_result_query_does_not_open_the_backends_circuit(monkeypatch):
480
+ """An empty result is ambiguous - it can mean the query genuinely has
481
+ no hits, or that the backend served an anti-bot page that parsed to
482
+ nothing. The circuit breaker is a process-wide singleton, so counting
483
+ the ambiguous case meant three unusual-but-legitimate queries in a row
484
+ could disable the primary backend for every user of the deployment for
485
+ a full cooldown.
486
+ """
487
+ from circuit_breaker import CircuitBreaker
488
+ from serp import DuckDuckGoBlockedException
489
+
490
+ breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
491
+ monkeypatch.setattr(app_module, "_backend_circuit_breaker", breaker)
492
+
493
+ ddg_calls = []
494
+
495
+ async def empty_ddg(q, n):
496
+ ddg_calls.append(q)
497
+ raise DuckDuckGoBlockedException()
498
+
499
+ async def fake_brave(browser, q, n):
500
+ return [{"title": "from brave"}]
501
+
502
+ monkeypatch.setattr(app_module, "query_ddg_search", empty_ddg)
503
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
504
+
505
+ await app_module._search_one("obscure query", 5)
506
+ await app_module._search_one("another obscure query", 5)
507
+
508
+ # Still being tried: an empty result is not evidence the backend is down.
509
+ assert ddg_calls == ["obscure query", "another obscure query"]
510
+
511
+
512
+ async def test_an_empty_result_still_falls_through_to_the_next_backend(monkeypatch):
513
+ """Not counting it against the backend must not stop the fallback: the
514
+ caller still needs results from somewhere."""
515
+ from serp import DuckDuckGoBlockedException
516
+
517
+ async def empty_ddg(q, n):
518
+ raise DuckDuckGoBlockedException()
519
+
520
+ async def fake_brave(browser, q, n):
521
+ return [{"title": "from brave"}]
522
+
523
+ monkeypatch.setattr(app_module, "query_ddg_search", empty_ddg)
524
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
525
+
526
+ _q, results, err = await app_module._search_one("widget", 5)
527
+
528
+ assert results == [{"title": "from brave"}]
529
+ assert err is None
530
+
531
+
532
+ async def test_a_real_backend_error_still_opens_the_circuit(monkeypatch):
533
+ """The distinction has to cut both ways: a backend that raises a
534
+ genuine error (a rate limit, a transport failure) is still evidence,
535
+ and must still trip the breaker.
536
+ """
537
+ from circuit_breaker import CircuitBreaker
538
+
539
+ breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=60)
540
+ monkeypatch.setattr(app_module, "_backend_circuit_breaker", breaker)
541
+
542
+ ddg_calls = []
543
+
544
+ async def failing_ddg(q, n):
545
+ ddg_calls.append(q)
546
+ raise RuntimeError("rate limited")
547
+
548
+ async def fake_brave(browser, q, n):
549
+ return [{"title": "from brave"}]
550
+
551
+ monkeypatch.setattr(app_module, "query_ddg_search", failing_ddg)
552
+ monkeypatch.setattr(app_module, "query_brave_search", fake_brave)
553
+
554
+ await app_module._search_one("first", 5)
555
+ await app_module._search_one("second", 5)
556
+
557
+ assert ddg_calls == ["first"] # skipped the second time
tests/test_utils.py CHANGED
@@ -1,15 +1,13 @@
1
  import logging
2
 
3
- from serp import SerpQuery
4
  from utils import log_gathered_exceptions
5
 
6
 
7
  def test_logs_only_the_exceptions_with_their_query(caplog):
8
- params = SerpQuery(queries=["q1", "q2", "q3"])
9
  results = ["ok result", ValueError("boom"), "another ok"]
10
 
11
  with caplog.at_level(logging.WARNING):
12
- log_gathered_exceptions(results, "test context", params)
13
 
14
  warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
15
  assert len(warnings) == 1
@@ -19,11 +17,8 @@ def test_logs_only_the_exceptions_with_their_query(caplog):
19
 
20
 
21
  def test_logs_nothing_when_all_results_succeeded(caplog):
22
- params = SerpQuery(queries=["q1", "q2"])
23
- results = ["ok", "also ok"]
24
-
25
  with caplog.at_level(logging.WARNING):
26
- log_gathered_exceptions(results, "test context", params)
27
 
28
  assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
29
 
@@ -33,8 +28,29 @@ def test_uses_its_own_logger_not_the_asyncio_one(caplog):
33
  misattributed these warnings to the 'asyncio' logging namespace instead
34
  of the app's own. Fixed to `logging.getLogger(__name__)`.
35
  """
36
- params = SerpQuery(queries=["q1"])
37
  with caplog.at_level(logging.WARNING):
38
- log_gathered_exceptions([ValueError("boom")], "ctx", params)
39
 
40
  assert caplog.records[0].name == "utils"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import logging
2
 
 
3
  from utils import log_gathered_exceptions
4
 
5
 
6
  def test_logs_only_the_exceptions_with_their_query(caplog):
 
7
  results = ["ok result", ValueError("boom"), "another ok"]
8
 
9
  with caplog.at_level(logging.WARNING):
10
+ log_gathered_exceptions(results, "test context", ["q1", "q2", "q3"])
11
 
12
  warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
13
  assert len(warnings) == 1
 
17
 
18
 
19
  def test_logs_nothing_when_all_results_succeeded(caplog):
 
 
 
20
  with caplog.at_level(logging.WARNING):
21
+ log_gathered_exceptions(["ok", "also ok"], "test context", ["q1", "q2"])
22
 
23
  assert [r for r in caplog.records if r.levelno == logging.WARNING] == []
24
 
 
28
  misattributed these warnings to the 'asyncio' logging namespace instead
29
  of the app's own. Fixed to `logging.getLogger(__name__)`.
30
  """
 
31
  with caplog.at_level(logging.WARNING):
32
+ log_gathered_exceptions([ValueError("boom")], "ctx", ["q1"])
33
 
34
  assert caplog.records[0].name == "utils"
35
+
36
+
37
+ def test_takes_a_plain_sequence_of_queries(caplog):
38
+ """This helper is the lowest layer in the codebase; it used to import
39
+ SerpQuery from serp.py purely to read one attribute, inverting the
40
+ dependency. It now accepts any iterable of query strings, so the patent
41
+ and OPS paths can use it too.
42
+ """
43
+ with caplog.at_level(logging.WARNING):
44
+ log_gathered_exceptions([ValueError("boom")], "ctx", iter(["only-query"]))
45
+
46
+ assert "only-query" in caplog.records[0].message
47
+
48
+
49
+ def test_extra_results_without_a_matching_query_are_ignored(caplog):
50
+ """zip stops at the shorter sequence - pinning that so a mismatch can't
51
+ raise inside an error-logging path.
52
+ """
53
+ with caplog.at_level(logging.WARNING):
54
+ log_gathered_exceptions([ValueError("a"), ValueError("b")], "ctx", ["q1"])
55
+
56
+ assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 1
utils.py CHANGED
@@ -1,12 +1,20 @@
1
  import logging
2
-
3
- from serp import SerpQuery
4
 
5
  logger = logging.getLogger(__name__)
6
 
7
 
8
- def log_gathered_exceptions(results: list, context: str, params: SerpQuery):
9
- """Logs gathered exceptions with context and parameters."""
10
- for exc, q in zip(results, params.queries):
 
 
 
 
 
 
 
 
 
11
  if isinstance(exc, Exception):
12
  logger.warning(f"Error during {context} for query '{q}': {exc}")
 
1
  import logging
2
+ from typing import Iterable
 
3
 
4
  logger = logging.getLogger(__name__)
5
 
6
 
7
+ def log_gathered_exceptions(results: list, context: str, queries: Iterable[str]):
8
+ """Log the exceptions in a `return_exceptions=True` gather, named by the
9
+ query that produced each.
10
+
11
+ Takes the queries themselves rather than a SerpQuery: this is the
12
+ lowest layer in the codebase and importing the domain model inverted
13
+ the dependency for the sake of one attribute access. It also means the
14
+ patent and OPS paths can use it.
15
+ """
16
+ # strict=False deliberately: this is an error-logging path and must
17
+ # never raise on a length mismatch of its own.
18
+ for exc, q in zip(results, queries, strict=False):
19
  if isinstance(exc, Exception):
20
  logger.warning(f"Error during {context} for query '{q}': {exc}")