Skip to content

Commit baf396f

Browse files
authored
Unify the PRO API request path for the metadata and data helpers (#397)
1 parent 2297535 commit baf396f

6 files changed

Lines changed: 218 additions & 14 deletions

File tree

SPEC.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -567,18 +567,22 @@ Credit-exhaustion and rate-limit responses from the PRO API are currently not sp
567567

568568
7. **HTTP Request Robustness**
569569

570-
Blockscout HTTP requests are centralized via the helper `make_blockscout_request`. To improve resilience against transient, transport-level issues observed in real-world usage (for example, incomplete chunked reads), the helper employs a small and conservative retry policy:
570+
Blockscout PRO API requests are centralized through a single shared low-level core, `_make_blockscout_http_request`, which backs every PRO API request helper: `make_blockscout_request` (GET), `make_blockscout_post_request` (POST), and `make_metadata_request` (the non-chain-scoped address-metadata GET). To improve resilience against transient, transport-level issues observed in real-world usage (for example, incomplete chunked reads), this core employs a small and conservative retry policy:
571571

572-
- Applies only to idempotent GETs (this function is GET-only)
573-
- Retries up to 3 attempts on `httpx.RequestError` (transport errors)
574-
- Does not retry on `httpx.HTTPStatusError` (4xx/5xx responses)
572+
- The retry **exception set is selected by each helper**, because idempotency differs by HTTP method:
573+
- The idempotent GET helpers `make_blockscout_request` and `make_metadata_request` retry on `httpx.RequestError` (transport errors, which include `httpx.TimeoutException`).
574+
- The non-idempotent `make_blockscout_post_request` deliberately narrows its retry set to connection-establishment failures only (`httpx.ConnectError`, `httpx.ConnectTimeout`), so a POST that may already have reached the server is never silently re-sent.
575+
- Retries up to 3 attempts (configurable; see below)
576+
- Never retries on `httpx.HTTPStatusError` (4xx/5xx responses), for any helper
575577
- Uses short exponential backoff between attempts (0.5s, then 1.0s)
576578

577579
Configuration:
578580
- The maximum number of retry attempts is configurable via the environment variable `BLOCKSCOUT_BS_REQUEST_MAX_RETRIES` (default: `3`).
579581

580582
This keeps API semantics intact, avoids masking persistent upstream problems, and improves reliability for both MCP tools and the REST API endpoints that proxy through the same business logic.
581583

584+
Because all PRO API helpers share this core, their HTTP-status-error enrichment and JSON-`null`-body normalization are identical, and they share the same retry orchestration (attempt count and backoff schedule); only the set of exceptions treated as retryable differs by helper, as detailed in the bullets above. In particular, `make_metadata_request` — used by `get_address_info` — now inherits the shared GET retry policy (retrying `httpx.RequestError`, which includes `httpx.TimeoutException`), the same `"<code> <reason> - Details: …"` error enrichment, and the same normalization of a JSON `null` body to an empty object that the primary data path already provides.
585+
582586
Exhausted internal retries surface differently per access mode:
583587
- **REST clients** see `500 Internal Server Error` for generic transport failures, or `504 Gateway Timeout` for `httpx.TimeoutException`. Because the server has already retried internally, downstream retry policies that also retry on `5xx` should stay conservative on `500`/`504` from this server to avoid multiplicative attempt cascades.
584588
- **Native MCP clients** see a `tools/call` result with `isError: true` and a text content of the form `"Error executing tool <name>: <exception message>"`. There is no HTTP-status indicator in MCP mode — an exhausted-retry transport failure is structurally indistinguishable from an honest upstream `5xx` (the latter carries a `"<code> <reason> - Details: …"` prefix in the text; the former carries the bare `httpx` exception message).

blockscout_mcp_server/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# SPDX-License-Identifier: LicenseRef-Blockscout
22
"""Blockscout MCP Server package."""
33

4-
__version__ = "0.16.0.dev13"
4+
__version__ = "0.16.0.dev14"

blockscout_mcp_server/tools/common.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,27 +378,43 @@ async def make_metadata_request(api_path: str, params: dict | None = None) -> di
378378
PRO API is guaranteed to reject. Callers treat this like any other
379379
metadata failure (the ``metadata`` field is null with an explanatory note).
380380
381+
This helper routes through the shared ``_make_blockscout_http_request`` core
382+
and therefore inherits the same conservative GET retry policy
383+
(``httpx.RequestError`` retries) and ``"<code> <reason> - Details: …"``
384+
error enrichment as ``make_blockscout_request``. The metadata endpoint is
385+
not chain-scoped, so this helper calls the low-level core directly with
386+
``base_url=config.pro_api_base_url`` — bypassing ``ensure_chain_supported``
387+
and the ``/{chain_id}`` segment that ``make_blockscout_request`` would add.
388+
381389
Args:
382390
api_path: The API path to request
383391
params: Optional query parameters
384392
385393
Returns:
386-
The JSON response as a dictionary
394+
The JSON response as a dictionary. A JSON ``null`` response body is
395+
normalized to ``{}``.
387396
388397
Raises:
389398
ValueError: If ``BLOCKSCOUT_PRO_API_KEY`` is not configured
390399
httpx.HTTPStatusError: If the HTTP request returns an error status code
391-
httpx.TimeoutException: If the request times out
400+
httpx.TimeoutException: If the request times out (retried as a subclass
401+
of ``httpx.RequestError`` before being surfaced)
402+
httpx.RequestError: For transport-level errors surfaced after the final
403+
retry
392404
"""
393405
if not config.pro_api_key:
394406
raise ValueError(
395407
"Blockscout PRO API key is not configured (set BLOCKSCOUT_PRO_API_KEY); address metadata is disabled."
396408
)
397-
async with _create_httpx_client(timeout=config.metadata_timeout) as client:
398-
url = f"{config.pro_api_base_url}{api_path}"
399-
response = await client.get(url, params=params, headers=_pro_api_headers())
400-
response.raise_for_status()
401-
return response.json()
409+
return await _make_blockscout_http_request(
410+
method="GET",
411+
base_url=config.pro_api_base_url,
412+
api_path=api_path,
413+
retry_exceptions=(httpx.RequestError,),
414+
headers=_pro_api_headers(),
415+
params=params,
416+
timeout=config.metadata_timeout,
417+
)
402418

403419

404420
async def make_request_with_periodic_progress(

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "blockscout-mcp-server"
3-
version = "0.16.0.dev13"
3+
version = "0.16.0.dev14"
44
description = "MCP server for Blockscout"
55
requires-python = ">=3.11"
66
dependencies = [

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
33
"name": "com.blockscout/mcp-server",
44
"description": "MCP server for Blockscout",
5-
"version": "0.16.0.dev13",
5+
"version": "0.16.0.dev14",
66
"websiteUrl": "https://blockscout.com",
77
"repository": {
88
"url": "https://github.com/blockscout/mcp-server",

tests/tools/test_common_metadata.py

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,190 @@ def _fail_create_client(*args, **kwargs):
140140
await make_metadata_request("/services/metadata/api/v1/metadata", {"addresses": "0xabc"})
141141

142142

143+
# ---------------------------------------------------------------------------
144+
# New behaviors acquired by routing through _make_blockscout_http_request
145+
# ---------------------------------------------------------------------------
146+
147+
148+
@pytest.mark.asyncio
149+
async def test_make_metadata_request_uses_metadata_timeout(monkeypatch):
150+
"""make_metadata_request creates the HTTP client with config.metadata_timeout.
151+
152+
This guards the most likely silent regression: an implementation that forgets
153+
the explicit timeout= argument and lets the core fall back to config.bs_timeout
154+
(120s), silently widening the metadata budget from 30s to 120s.
155+
"""
156+
monkeypatch.setattr(config, "pro_api_key", "api_key_12345")
157+
api_path = "/api/v1/metadata/address"
158+
159+
request = httpx.Request("GET", f"{config.pro_api_base_url}{api_path}")
160+
ok_resp = httpx.Response(200, json={"result": "ok"}, request=request)
161+
162+
class _MockClient:
163+
async def __aenter__(self):
164+
return self
165+
166+
async def __aexit__(self, *args):
167+
return None
168+
169+
async def get(self, url, **kwargs):
170+
return ok_resp
171+
172+
with patch(
173+
"blockscout_mcp_server.tools.common._create_httpx_client",
174+
return_value=_MockClient(),
175+
) as mock_create_client:
176+
await make_metadata_request(api_path)
177+
178+
mock_create_client.assert_called_once_with(timeout=config.metadata_timeout)
179+
180+
181+
@pytest.mark.asyncio
182+
async def test_make_metadata_request_retries_then_succeeds(monkeypatch):
183+
"""make_metadata_request retries on httpx.RequestError and returns result on success.
184+
185+
Simulates two transient failures followed by a successful response.
186+
Asserts that .get() is called exactly 3 times and anyio.sleep is awaited twice
187+
(once per backoff between attempts).
188+
The retry cap is pinned to 3 via monkeypatch so the assertion is deterministic.
189+
"""
190+
monkeypatch.setattr(config, "pro_api_key", "api_key_12345")
191+
monkeypatch.setattr(config, "bs_request_max_retries", 3)
192+
api_path = "/api/v1/metadata/address"
193+
194+
attempt_count = {"n": 0}
195+
196+
request = httpx.Request("GET", f"{config.pro_api_base_url}{api_path}")
197+
ok_resp = httpx.Response(200, json={"data": "value"}, request=request)
198+
199+
class _TransientClient:
200+
async def __aenter__(self):
201+
return self
202+
203+
async def __aexit__(self, *args):
204+
return None
205+
206+
async def get(self, url, **kwargs):
207+
attempt_count["n"] += 1
208+
if attempt_count["n"] < 3:
209+
raise httpx.RequestError("transient error")
210+
return ok_resp
211+
212+
with (
213+
patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_TransientClient()),
214+
patch("blockscout_mcp_server.tools.common.anyio.sleep") as mock_sleep,
215+
):
216+
result = await make_metadata_request(api_path)
217+
218+
assert result == {"data": "value"}
219+
assert attempt_count["n"] == 3
220+
assert mock_sleep.await_count == 2
221+
222+
223+
@pytest.mark.asyncio
224+
async def test_make_metadata_request_retry_exhaustion_raises(monkeypatch):
225+
"""make_metadata_request re-raises httpx.RequestError after all retries are exhausted.
226+
227+
With the retry cap pinned to 3, the client's .get() should be called exactly 3
228+
times before the error surfaces, and anyio.sleep should be awaited exactly twice.
229+
"""
230+
monkeypatch.setattr(config, "pro_api_key", "api_key_12345")
231+
monkeypatch.setattr(config, "bs_request_max_retries", 3)
232+
api_path = "/api/v1/metadata/address"
233+
234+
attempt_count = {"n": 0}
235+
236+
class _AlwaysFailingClient:
237+
async def __aenter__(self):
238+
return self
239+
240+
async def __aexit__(self, *args):
241+
return None
242+
243+
async def get(self, url, **kwargs):
244+
attempt_count["n"] += 1
245+
raise httpx.RequestError("persistent error")
246+
247+
with (
248+
patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_AlwaysFailingClient()),
249+
patch("blockscout_mcp_server.tools.common.anyio.sleep") as mock_sleep,
250+
):
251+
with pytest.raises(httpx.RequestError):
252+
await make_metadata_request(api_path)
253+
254+
assert attempt_count["n"] == 3
255+
assert mock_sleep.await_count == 2
256+
257+
258+
@pytest.mark.asyncio
259+
async def test_make_metadata_request_null_body_normalized_to_empty_dict(monkeypatch):
260+
"""A JSON null response body is normalized to {} instead of None.
261+
262+
This prevents a latent AttributeError in the caller (get_address_info calls
263+
.get("addresses") on the result, which would fail on None).
264+
"""
265+
monkeypatch.setattr(config, "pro_api_key", "api_key_12345")
266+
api_path = "/api/v1/metadata/address"
267+
268+
request = httpx.Request("GET", f"{config.pro_api_base_url}{api_path}")
269+
# Use content=b"null" so httpx.Response.json() returns Python None (not an empty body).
270+
null_resp = httpx.Response(200, content=b"null", headers={"content-type": "application/json"}, request=request)
271+
272+
class _NullBodyClient:
273+
async def __aenter__(self):
274+
return self
275+
276+
async def __aexit__(self, *args):
277+
return None
278+
279+
async def get(self, url, **kwargs):
280+
return null_resp
281+
282+
with patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_NullBodyClient()):
283+
result = await make_metadata_request(api_path)
284+
285+
assert result == {}
286+
287+
288+
@pytest.mark.asyncio
289+
async def test_make_metadata_request_enriched_error_message_and_no_retry_on_http_error(monkeypatch):
290+
"""HTTP error status raises HTTPStatusError with enriched message and is not retried.
291+
292+
Asserts:
293+
- The error message contains the status code and 'Details:' segment.
294+
- The client's .get() is called exactly once (HTTP errors are not retried).
295+
"""
296+
monkeypatch.setattr(config, "pro_api_key", "bad_key")
297+
monkeypatch.setattr(config, "bs_request_max_retries", 3)
298+
api_path = "/api/v1/metadata/address"
299+
300+
attempt_count = {"n": 0}
301+
302+
class _UnauthorizedClient:
303+
async def __aenter__(self):
304+
return self
305+
306+
async def __aexit__(self, *args):
307+
return None
308+
309+
async def get(self, url, **kwargs):
310+
attempt_count["n"] += 1
311+
request = httpx.Request("GET", url)
312+
return httpx.Response(401, content=b"Unauthorized", request=request)
313+
314+
with (
315+
patch("blockscout_mcp_server.tools.common._create_httpx_client", return_value=_UnauthorizedClient()),
316+
patch("blockscout_mcp_server.tools.common.anyio.sleep") as mock_sleep,
317+
):
318+
with pytest.raises(httpx.HTTPStatusError) as exc_info:
319+
await make_metadata_request(api_path)
320+
321+
assert "401" in str(exc_info.value)
322+
assert "Details:" in str(exc_info.value)
323+
assert attempt_count["n"] == 1
324+
mock_sleep.assert_not_called()
325+
326+
143327
# ---------------------------------------------------------------------------
144328
# Security: PRO API key MUST be sent to the PRO API host via make_blockscout_request
145329
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)