Spaces:
Running
Stop reporting upstream failures as "patent not found"
Browse files`scrap_patent` caught HTTPStatusError and bare Exception identically and
fell through to a single 404, so every failure mode collapsed into "not
found": a 503 from Google Patents, a connect timeout, and an unparseable
interstitial all told the caller the patent does not exist.
That is worse than an inaccurate status code here, because the MCP
instructions this server ships tell the model in as many words: "A patent
that returns 'not found' is genuinely absent from every backend; move on
to the next id rather than retrying." Correct advice given a truthful 404,
actively harmful given this one - a transient blip taught an agent, with
the server's explicit encouragement, that a real patent does not exist.
Now 404 is reserved for a backend positively saying the document is
absent, and is only returned when *both* backends said so (or when the
only backend asked did). Upstream failures map to 502, timeouts to 504.
The same mapping is applied to the direct /ops endpoint.
tests/test_patent_fallback.py covers the whole matrix, which previously
had no tests at all: the happy path skipping OPS entirely, a real 404,
four upstream error statuses, a timeout, an unparseable page, the fallback
succeeding after Google Patents fails, both backends missing, and OPS
failing to answer after a Google Patents 404 - unknown, not absent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MSNSYnceqvdVz7Csis4K9e
- app.py +41 -12
- tests/test_patent_fallback.py +195 -0
|
@@ -275,6 +275,26 @@ async def search(params: SerpQuery):
|
|
| 275 |
# =========================== Scrapping endpoints ===========================
|
| 276 |
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
@scrap_router.get("/scrap_patent/{patent_id}")
|
| 279 |
async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
|
| 280 |
"""Scraps the specified patent from Google Patents.
|
|
@@ -282,34 +302,42 @@ async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
|
|
| 282 |
Falls back to the EPO OPS API (which covers patents missing from Google
|
| 283 |
Patents) when the scrape fails and OPS credentials are configured.
|
| 284 |
"""
|
|
|
|
| 285 |
try:
|
| 286 |
return await scrap_patent_async(httpx_client, f"https://patents.google.com/patent/{patent_id}/en")
|
| 287 |
except HTTPStatusError as e:
|
|
|
|
| 288 |
logging.warning(
|
| 289 |
f"Google Patents returned {e.response.status_code} for {patent_id}.")
|
| 290 |
except Exception as e:
|
|
|
|
| 291 |
logging.warning(f"Failed to scrap patent {patent_id}: {e}")
|
| 292 |
|
| 293 |
if not ops_token_manager.configured:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
raise HTTPException(
|
| 295 |
-
status_code=
|
| 296 |
-
detail=f"
|
| 297 |
|
| 298 |
try:
|
| 299 |
logging.info(f"Trying OPS for patent {patent_id}.")
|
| 300 |
return await ops_scrap_patent(httpx_client, patent_id)
|
| 301 |
-
except
|
| 302 |
-
|
|
|
|
|
|
|
| 303 |
raise HTTPException(
|
| 304 |
status_code=404,
|
| 305 |
detail=f"Patent '{patent_id}' not found on Google Patents or EPO OPS.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
raise HTTPException(
|
| 307 |
-
status_code=
|
| 308 |
-
detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
|
| 309 |
-
except Exception as e:
|
| 310 |
-
logging.warning(f"OPS fallback failed for {patent_id}: {e}")
|
| 311 |
-
raise HTTPException(
|
| 312 |
-
status_code=502,
|
| 313 |
detail=f"EPO OPS request failed for '{patent_id}': {e}")
|
| 314 |
|
| 315 |
|
|
@@ -360,12 +388,13 @@ async def ops_get_patent(patent_id: PatentId) -> PatentScrapResult:
|
|
| 360 |
raise HTTPException(
|
| 361 |
status_code=404, detail=f"Patent '{patent_id}' not found in EPO OPS.")
|
| 362 |
raise HTTPException(
|
| 363 |
-
status_code=
|
| 364 |
detail=f"EPO OPS returned {e.response.status_code} for '{patent_id}'.")
|
| 365 |
except Exception as e:
|
| 366 |
logging.warning(f"Failed to retrieve patent {patent_id} from OPS: {e}")
|
| 367 |
raise HTTPException(
|
| 368 |
-
status_code=
|
|
|
|
| 369 |
|
| 370 |
|
| 371 |
@ops_router.post("/scrap_patents_bulk", response_model=OPSBulkResponse)
|
|
|
|
| 275 |
# =========================== Scrapping endpoints ===========================
|
| 276 |
|
| 277 |
|
| 278 |
+
def _is_not_found(exc: Exception) -> bool:
|
| 279 |
+
"""True only when a backend positively said the document is absent.
|
| 280 |
+
|
| 281 |
+
Anything else - a 5xx, a rate-limit, a timeout, an unparseable page -
|
| 282 |
+
is a failure to answer, not an answer.
|
| 283 |
+
"""
|
| 284 |
+
return isinstance(exc, HTTPStatusError) and exc.response.status_code == 404
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _upstream_status(exc: Exception) -> int:
|
| 288 |
+
"""The status to report for a failed upstream call. Never 404.
|
| 289 |
+
|
| 290 |
+
404 is a claim that the patent does not exist, and MCP_INSTRUCTIONS
|
| 291 |
+
tells agents to believe it and move on without retrying - so it has to
|
| 292 |
+
be reserved for a backend actually saying so. A transient outage
|
| 293 |
+
reported as 404 teaches an agent that a real patent is absent.
|
| 294 |
+
"""
|
| 295 |
+
return 504 if isinstance(exc, httpx.TimeoutException) else 502
|
| 296 |
+
|
| 297 |
+
|
| 298 |
@scrap_router.get("/scrap_patent/{patent_id}")
|
| 299 |
async def scrap_patent(patent_id: PatentId) -> PatentScrapResult:
|
| 300 |
"""Scraps the specified patent from Google Patents.
|
|
|
|
| 302 |
Falls back to the EPO OPS API (which covers patents missing from Google
|
| 303 |
Patents) when the scrape fails and OPS credentials are configured.
|
| 304 |
"""
|
| 305 |
+
google_error: Exception
|
| 306 |
try:
|
| 307 |
return await scrap_patent_async(httpx_client, f"https://patents.google.com/patent/{patent_id}/en")
|
| 308 |
except HTTPStatusError as e:
|
| 309 |
+
google_error = e
|
| 310 |
logging.warning(
|
| 311 |
f"Google Patents returned {e.response.status_code} for {patent_id}.")
|
| 312 |
except Exception as e:
|
| 313 |
+
google_error = e
|
| 314 |
logging.warning(f"Failed to scrap patent {patent_id}: {e}")
|
| 315 |
|
| 316 |
if not ops_token_manager.configured:
|
| 317 |
+
if _is_not_found(google_error):
|
| 318 |
+
raise HTTPException(
|
| 319 |
+
status_code=404,
|
| 320 |
+
detail=f"Patent '{patent_id}' not found on Google Patents (EPO OPS fallback not configured).")
|
| 321 |
raise HTTPException(
|
| 322 |
+
status_code=_upstream_status(google_error),
|
| 323 |
+
detail=f"Google Patents request failed for '{patent_id}' and the EPO OPS fallback is not configured: {google_error}")
|
| 324 |
|
| 325 |
try:
|
| 326 |
logging.info(f"Trying OPS for patent {patent_id}.")
|
| 327 |
return await ops_scrap_patent(httpx_client, patent_id)
|
| 328 |
+
except Exception as e:
|
| 329 |
+
logging.warning(f"OPS fallback failed for {patent_id}: {e}")
|
| 330 |
+
# Only claim the patent doesn't exist when *both* backends said so.
|
| 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 |
|
|
|
|
| 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)
|
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The Google Patents -> EPO OPS fallback chain in app.py.
|
| 2 |
+
|
| 3 |
+
This is the most heavily-branched code in the module - scrape, catch,
|
| 4 |
+
check whether OPS is configured, fall back, then map OPS's own failures
|
| 5 |
+
onto status codes - and it had no test coverage at all.
|
| 6 |
+
|
| 7 |
+
The distinction these tests exist to protect: a 404 means "this patent
|
| 8 |
+
does not exist", and an agent is explicitly told by MCP_INSTRUCTIONS to
|
| 9 |
+
believe it and move on without retrying. Anything else - an upstream
|
| 10 |
+
outage, a timeout, a blocked scrape - must not be reported that way.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
import pytest
|
| 15 |
+
from httpx import ASGITransport, HTTPStatusError, Request, Response
|
| 16 |
+
|
| 17 |
+
import app as app_module
|
| 18 |
+
from scrap import PatentScrapResult
|
| 19 |
+
|
| 20 |
+
PATENT_ID = "US11930446B2"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@pytest.fixture
|
| 24 |
+
async def client():
|
| 25 |
+
transport = ASGITransport(app=app_module.app)
|
| 26 |
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
| 27 |
+
yield c
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.fixture
|
| 31 |
+
def ops_configured(monkeypatch):
|
| 32 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
|
| 33 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@pytest.fixture
|
| 37 |
+
def ops_unconfigured(monkeypatch):
|
| 38 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
|
| 39 |
+
monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _http_error(status: int) -> HTTPStatusError:
|
| 43 |
+
request = Request("GET", "https://patents.google.com/")
|
| 44 |
+
return HTTPStatusError(
|
| 45 |
+
str(status), request=request, response=Response(status, request=request))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _raises(exc):
|
| 49 |
+
async def _fn(*args, **kwargs):
|
| 50 |
+
raise exc
|
| 51 |
+
return _fn
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ------------------------------- the happy path -------------------------------
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def test_successful_scrape_never_touches_ops(client, ops_configured, monkeypatch):
|
| 58 |
+
ops_called = False
|
| 59 |
+
|
| 60 |
+
async def fake_ops(*args, **kwargs):
|
| 61 |
+
nonlocal ops_called
|
| 62 |
+
ops_called = True
|
| 63 |
+
return PatentScrapResult(title="from OPS")
|
| 64 |
+
|
| 65 |
+
monkeypatch.setattr(
|
| 66 |
+
app_module, "scrap_patent_async",
|
| 67 |
+
lambda c, url: _ok(PatentScrapResult(title="from Google Patents")))
|
| 68 |
+
monkeypatch.setattr(app_module, "ops_scrap_patent", fake_ops)
|
| 69 |
+
|
| 70 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 71 |
+
|
| 72 |
+
assert resp.status_code == 200
|
| 73 |
+
assert resp.json()["title"] == "from Google Patents"
|
| 74 |
+
assert ops_called is False
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
async def _ok(value):
|
| 78 |
+
return value
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ------------------------- genuine miss vs upstream failure -------------------------
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
async def test_a_real_404_from_google_patents_is_a_404(client, ops_unconfigured, monkeypatch):
|
| 85 |
+
"""The one case where "not found" is the truth."""
|
| 86 |
+
monkeypatch.setattr(app_module, "scrap_patent_async", _raises(_http_error(404)))
|
| 87 |
+
|
| 88 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 89 |
+
|
| 90 |
+
assert resp.status_code == 404
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@pytest.mark.parametrize("status", [429, 500, 502, 503])
|
| 94 |
+
async def test_an_upstream_failure_is_not_reported_as_not_found(
|
| 95 |
+
client, ops_unconfigured, monkeypatch, status):
|
| 96 |
+
"""MCP_INSTRUCTIONS tells the model a "not found" patent is genuinely
|
| 97 |
+
absent everywhere and to move on rather than retrying. Reporting a
|
| 98 |
+
transient upstream failure that way teaches an agent - with the
|
| 99 |
+
server's explicit encouragement - that a real patent does not exist.
|
| 100 |
+
"""
|
| 101 |
+
monkeypatch.setattr(app_module, "scrap_patent_async", _raises(_http_error(status)))
|
| 102 |
+
|
| 103 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 104 |
+
|
| 105 |
+
assert resp.status_code == 502, f"upstream {status} was reported as {resp.status_code}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
async def test_a_timeout_is_reported_as_a_gateway_timeout(client, ops_unconfigured, monkeypatch):
|
| 109 |
+
monkeypatch.setattr(
|
| 110 |
+
app_module, "scrap_patent_async", _raises(httpx.ConnectTimeout("timed out")))
|
| 111 |
+
|
| 112 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 113 |
+
|
| 114 |
+
assert resp.status_code == 504
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def test_an_unparseable_page_is_reported_as_a_bad_gateway(
|
| 118 |
+
client, ops_unconfigured, monkeypatch):
|
| 119 |
+
"""parse_patent_html raises ValueError when the page isn't a patent page
|
| 120 |
+
(an interstitial, or a markup change). That is our problem or theirs,
|
| 121 |
+
but it is not evidence the patent doesn't exist.
|
| 122 |
+
"""
|
| 123 |
+
monkeypatch.setattr(
|
| 124 |
+
app_module, "scrap_patent_async", _raises(ValueError("no DC.title")))
|
| 125 |
+
|
| 126 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 127 |
+
|
| 128 |
+
assert resp.status_code == 502
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
# --------------------------------- the OPS fallback ---------------------------------
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
async def test_falls_back_to_ops_when_google_patents_fails(client, ops_configured, monkeypatch):
|
| 135 |
+
monkeypatch.setattr(app_module, "scrap_patent_async", _raises(_http_error(503)))
|
| 136 |
+
monkeypatch.setattr(
|
| 137 |
+
app_module, "ops_scrap_patent",
|
| 138 |
+
lambda c, pid: _ok(PatentScrapResult(title="from OPS")))
|
| 139 |
+
|
| 140 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 141 |
+
|
| 142 |
+
assert resp.status_code == 200
|
| 143 |
+
assert resp.json()["title"] == "from OPS"
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
async def test_missing_from_both_backends_is_a_404(client, ops_configured, monkeypatch):
|
| 147 |
+
monkeypatch.setattr(app_module, "scrap_patent_async", _raises(_http_error(404)))
|
| 148 |
+
monkeypatch.setattr(app_module, "ops_scrap_patent", _raises(_http_error(404)))
|
| 149 |
+
|
| 150 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 151 |
+
|
| 152 |
+
assert resp.status_code == 404
|
| 153 |
+
assert "not found" in resp.json()["detail"].lower()
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
async def test_ops_failing_after_a_google_patents_404_is_not_a_404(
|
| 157 |
+
client, ops_configured, monkeypatch):
|
| 158 |
+
"""Google Patents says the patent is missing, but OPS - the backend that
|
| 159 |
+
covers what Google Patents doesn't - never answered. That is unknown,
|
| 160 |
+
not absent.
|
| 161 |
+
"""
|
| 162 |
+
monkeypatch.setattr(app_module, "scrap_patent_async", _raises(_http_error(404)))
|
| 163 |
+
monkeypatch.setattr(app_module, "ops_scrap_patent", _raises(_http_error(500)))
|
| 164 |
+
|
| 165 |
+
resp = await client.get(f"/scrap/scrap_patent/{PATENT_ID}")
|
| 166 |
+
|
| 167 |
+
assert resp.status_code == 502
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ------------------------------ the direct /ops endpoint ------------------------------
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
async def test_ops_endpoint_reports_a_timeout_as_a_gateway_timeout(
|
| 174 |
+
client, ops_configured, monkeypatch):
|
| 175 |
+
monkeypatch.setattr(
|
| 176 |
+
app_module, "ops_scrap_patent", _raises(httpx.ConnectTimeout("timed out")))
|
| 177 |
+
|
| 178 |
+
resp = await client.get(f"/ops/scrap_patent/{PATENT_ID}")
|
| 179 |
+
|
| 180 |
+
assert resp.status_code == 504
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def test_ops_endpoint_reports_a_real_miss_as_404(client, ops_configured, monkeypatch):
|
| 184 |
+
"""Here OPS is the only backend asked, so its 404 is the whole answer."""
|
| 185 |
+
monkeypatch.setattr(app_module, "ops_scrap_patent", _raises(_http_error(404)))
|
| 186 |
+
|
| 187 |
+
resp = await client.get(f"/ops/scrap_patent/{PATENT_ID}")
|
| 188 |
+
|
| 189 |
+
assert resp.status_code == 404
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
async def test_ops_endpoint_is_503_when_not_configured(client, ops_unconfigured):
|
| 193 |
+
resp = await client.get(f"/ops/scrap_patent/{PATENT_ID}")
|
| 194 |
+
|
| 195 |
+
assert resp.status_code == 503
|