Spaces:
Sleeping
Housekeeping: lint in CI, client lifecycle, and a truer breaker signal
Browse filesIndependent 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 +3 -0
- .github/workflows/test.yml +3 -0
- CONTRIBUTING.md +25 -3
- README.md +5 -3
- app.py +46 -22
- pyproject.toml +17 -0
- requirements-dev.txt +2 -0
- scrap.py +0 -1
- serp.py +24 -9
- tests/test_app.py +136 -0
- tests/test_utils.py +25 -9
- utils.py +13 -5
|
@@ -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 |
|
|
@@ -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
|
|
@@ -26,10 +26,11 @@ dependency update broke this."
|
|
| 26 |
## Running tests
|
| 27 |
|
| 28 |
```bash
|
|
|
|
| 29 |
pytest
|
| 30 |
```
|
| 31 |
|
| 32 |
-
|
| 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
|
| 97 |
-
request against `main`, so a change gets feedback
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -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
|
| 27 |
-
`tests/
|
| 28 |
-
or risk tripping, any site's anti-bot
|
|
|
|
| 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.
|
|
@@ -4,18 +4,18 @@ import os
|
|
| 4 |
import secrets
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
from typing import Annotated, Optional
|
| 7 |
-
from fastapi import
|
| 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 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 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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 |
+
]
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 70 |
-
|
| 71 |
-
|
| 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 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
|
|
@@ -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
|
|
@@ -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",
|
| 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(
|
| 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",
|
| 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
|
|
@@ -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,
|
| 9 |
-
"""
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}")
|