Claude Claude Opus 5 commited on
Commit
2e4089d
·
unverified ·
1 Parent(s): d4440e7

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

Files changed (2) hide show
  1. app.py +41 -12
  2. tests/test_patent_fallback.py +195 -0
app.py CHANGED
@@ -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=404,
296
- detail=f"Patent '{patent_id}' not found on Google Patents (EPO OPS fallback not configured).")
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 HTTPStatusError as e:
302
- if e.response.status_code == 404:
 
 
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=502,
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=502,
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=502, detail=f"EPO OPS request failed for '{patent_id}': {e}")
 
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)
tests/test_patent_fallback.py ADDED
@@ -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