Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .planning/REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Backfilled 2026-07-25 alongside the phase, which was inserted as urgent work wit
- [x] **ERR-02**: Reintroducing an uncovered tool fails the DEFAULT unit suite (`tests/test_tool_error_handling.py`), and the detector carries a self-test so it cannot pass vacuously
- [x] **ERR-03**: A malformed upstream body is classified as an upstream failure, never as caller error — `shared/http.py:api_get` raises `httpx.DecodingError` (an `HTTPError`, not a `ValueError`) so it bypasses `except ValueError -> INVALID_INPUT` arms
- [x] **ERR-04**: Genuine argument-validation `ValueError`s still return `INVALID_INPUT` — the decode fix does not swallow real caller errors
- [x] **ERR-05**: Every shared client decodes JSON through `decode_json()`/`decode_json_bytes()`, never raw — ArcGIS Hub, OGC WFS, Socrata and the geojson parsers were missed by ERR-03, which guarded only `api_get` (Phase 20.3)

### MCP Prompts and Resources

Expand Down Expand Up @@ -472,6 +473,7 @@ Primary portal is **data.novascotia.ca** — a **Socrata** (Tyler Technologies)
| ERR-02 | Phase 20.2 | Complete |
| ERR-03 | Phase 20.2 | Complete |
| ERR-04 | Phase 20.2 | Complete |
| ERR-05 | Phase 20.3 | Complete |
| PR-01 | Phase 40 | Complete |
| PR-02 | Phase 40 | Complete |
| PR-03 | Phase 40 | Complete |
Expand Down
11 changes: 11 additions & 0 deletions .planning/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,17 @@ Plans:

- [x] 20.2-01-PLAN.md — Catch-all coverage for every tool + decode guard in api_get

### Phase 20.3: Route every shared portal client through the JSON decode guard (INSERTED)

**Goal:** Finish what Phase 20.2 started. 20.2 added the decode guard to `shared/http.py:api_get` and stopped there, but three of the four portal technologies decode JSON themselves: ArcGIS Hub and Socrata called `response.json()` raw, OGC WFS called `json.loads(response.content)`, and the ArcGIS geojson hot path decoded via `arcgis_hub._parse_raw_json` + `parsers._parse_geojson`. Because `json.JSONDecodeError` subclasses `ValueError`, a malformed body from any of those still reached the `except ValueError -> INVALID_INPUT` arms in five tools (saskatchewan fire-bans / crop-yields / mineral-mines, manitoba livestock-prices / provincial-waterways) — reproduced before the fix, both probes returned `INVALID_INPUT` for an upstream outage. Adds `decode_json()` / `decode_json_bytes()` to `shared/http.py`, routes all 14 decode sites through them, and adds a structural test so a raw decode cannot be reintroduced.
**Requirements**: ERR-05
**Depends on:** Phase 20.2
**Plans:** 1/1 plans complete

Plans:

- [x] 20.3-01 — decode_json helpers + route all 14 sites + structural guard

### Phase 21: New Brunswick Government Open Data

**Goal:** [To be planned]
Expand Down
9 changes: 5 additions & 4 deletions .planning/STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ current_phase: 20.2
current_phase_name: Normalize tool error handling and guard malformed upstream JSON
status: awaiting_merge
stopped_at: "Phase 20.2 merged to main (PR #3); next is Phase 21 (New Brunswick)"
last_updated: "2026-07-26T04:19:16.802Z"
last_updated: "2026-07-27T01:28:17.759Z"
last_activity: 2026-07-26
last_activity_desc: Phase 20.2 executed and verified; 317 live tests green
progress:
total_phases: 36
total_phases: 37
completed_phases: 16
total_plans: 77
total_plans: 78
completed_plans: 77
percent: 44
percent: 43
---

# Project State
Expand Down Expand Up @@ -413,6 +413,7 @@ Recent decisions affecting current work:
- Phase 40 added: MCP Prompts and Resources — workflow prompts for guided data exploration, static resources for reference data across all modules
- Phase 20.1 inserted after Phase 20: Remove UPSTREAM_ERROR escape-hatch pattern from all provincial integration tests (MB/SK/AB/QC/NS) and re-run live integration to surface masked upstream failures before pushing Phase 20 (URGENT)
- Phase 20.2 inserted after Phase 20.1: Normalize tool error handling and guard malformed upstream JSON — root cause of a masking class surfaced by Codex review on PR #2; sequenced before Phase 21 so ~19 future modules inherit the correct handler shape
- Phase 20.3 inserted after Phase 20.2: Route every shared portal client through the JSON decode guard — 20.2 fixed api_get only, leaving ArcGIS Hub, OGC WFS and Socrata exposed; 5 tools reproducibly returned INVALID_INPUT for an upstream outage

### Pending Todos

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Every module in `src/mcp_canada/modules/{name}/`:

**Every `@tool` must have catch-all error coverage** — enforced by `tests/test_tool_error_handling.py` in the default unit suite. Satisfy it with `@upstream_guard(<api_name>)` beneath `@tool` (preferred — it is additive, so any handlers inside the function still run first), a broad `except Exception`/`httpx.HTTPError`, or delegation to a module helper that has one. **Catching only `httpx.HTTPStatusError` is not enough:** it covers a 500 but not a timeout, a connect error or a malformed body, each of which escapes as a raw `ToolError`. Phase 20.2 found 108 of 271 tools in that state.

**Never let `json.JSONDecodeError` reach an `except ValueError` arm.** It subclasses `ValueError`, so an upstream answering HTTP 200 with an HTML error page gets reported as `INVALID_INPUT` — blaming the caller for someone else's outage, and failing live tests with a misleading code (`assert_live_or_transient` tolerates only `UPSTREAM_ERROR`/`RATE_LIMITED`/`UPSTREAM_UNAVAILABLE`). `shared/http.py:api_get` converts it to `httpx.DecodingError`, which is an `HTTPError` but not a `ValueError`. Any client calling `.json()` directly must do the same.
**Never decode JSON outside `decode_json()` / `decode_json_bytes()`** (both in `shared/http.py`) — enforced by `tests/test_upstream_error_classification.py`. `json.JSONDecodeError` subclasses `ValueError`, so a raw `response.json()` on an upstream HTML error page gets reported as `INVALID_INPUT` — blaming the caller for someone else's outage, and failing live tests with a misleading code (`assert_live_or_transient` tolerates only `UPSTREAM_ERROR`/`RATE_LIMITED`/`UPSTREAM_UNAVAILABLE`). The helpers raise `httpx.DecodingError`, which is an `HTTPError` but not a `ValueError`, so it reaches the catch-all instead. Phase 20.2 guarded `api_get` alone and left ArcGIS Hub, OGC WFS and Socrata exposed; Phase 20.3 routed all 14 decode sites through the helpers. Use `decode_json(response, url)` for a `Response`, `decode_json_bytes(content, url)` when you hold raw bytes.

**Every client function must:** return `(data, was_cached)`, use `cached_fetch()` + `get_limiter()`, flatten responses aggressively.

Expand Down
23 changes: 14 additions & 9 deletions src/mcp_canada/shared/arcgis_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import httpx

from mcp_canada.shared.http import decode_json, decode_json_bytes

from mcp_canada.shared.parsers import _parse_geojson

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -77,12 +79,12 @@ async def search_hub_datasets(
if httpx_client is not None:
response = await httpx_client.get(url, params=params)
response.raise_for_status()
return response.json()
return decode_json(response, url)

async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
return response.json()
return decode_json(response, url)


async def query_feature_service(
Expand Down Expand Up @@ -194,12 +196,12 @@ async def get_layer_metadata(
if httpx_client is not None:
response = await httpx_client.get(url, params=params)
response.raise_for_status()
data = response.json()
data = decode_json(response, url)
else:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
data = decode_json(response, url)

return {
"max_record_count": int(data.get("maxRecordCount", DEFAULT_PAGE_SIZE)),
Expand Down Expand Up @@ -244,12 +246,12 @@ async def get_count(
if httpx_client is not None:
response = await httpx_client.get(url, params=params)
response.raise_for_status()
data = response.json()
data = decode_json(response, url)
else:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params)
response.raise_for_status()
data = response.json()
data = decode_json(response, url)

return int(data.get("count", 0))

Expand Down Expand Up @@ -289,6 +291,9 @@ def shape_hub_dataset(feature: dict[str, Any]) -> dict[str, Any]:


def _parse_raw_json(content: bytes) -> dict[str, Any]:
"""Parse raw bytes as JSON and return the dict (for checking vendor extensions)."""
import json
return json.loads(content)
"""Parse raw bytes as JSON and return the dict (for checking vendor extensions).

Decodes via the shared helper so a malformed body raises httpx.DecodingError
rather than a ValueError subclass — see shared/http.py:decode_json.
"""
return decode_json_bytes(content)
50 changes: 35 additions & 15 deletions src/mcp_canada/shared/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,40 @@
)


def decode_json(response: httpx.Response, url: str = "") -> Any:
"""Parse a JSON body, raising ``httpx.DecodingError`` rather than a ValueError.

``json.JSONDecodeError`` subclasses ``ValueError``, so an upstream that
answers HTTP 200 with an HTML error page would otherwise be caught by the
``except ValueError -> INVALID_INPUT`` arms in saskatchewan and manitoba and
blamed on the caller. ``httpx.DecodingError`` is an ``httpx.HTTPError`` but
NOT a ``ValueError``, so it bypasses those arms and reaches the catch-all
every tool now has.

Every shared portal client must decode through this — Phase 20.2 guarded
only ``api_get``, which left the ArcGIS Hub, OGC WFS and Socrata paths
exposed (Phase 20.3).
"""
try:
return response.json()
except json.JSONDecodeError as exc:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch invalid byte encodings in the decode guard

When an upstream returns bytes that are not valid UTF-8/16/32 (for example b"\xff"), both response.json() and json.loads(content) raise UnicodeDecodeError, not json.JSONDecodeError. Because UnicodeDecodeError is also a ValueError, it escapes these new guards and the Saskatchewan/Manitoba except ValueError arms still report the upstream failure as INVALID_INPUT, contrary to the purpose of this change. Convert this decode failure to httpx.DecodingError in both helpers as well.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 30352d4. You are right, and I had considered UnicodeDecodeError earlier in this work and talked myself out of it as speculation — it is not speculative.

Verified your example before acting: json.loads(b"\xff") and response.json() on b"\xff" both raise UnicodeDecodeError, not JSONDecodeError. (My first probe used b"\xff\xfe\x00bad", which is a valid UTF-16 BOM, so it decoded and then failed as JSONDecodeError — that nearly led me to dismiss the report. Your exact byte was the one that mattered.)

Two changes:

  1. decode_json and decode_json_bytes now catch (json.JSONDecodeError, UnicodeDecodeError), as you asked.
  2. Beyond the report: shared/envelope.py:upstream_guard had the identical arm ordering, and I confirmed it returned INVALID_INPUT for a raw UnicodeDecodeError before fixing. That is the live path for every client that calls .json() itself — drug_database, nutrient_file, recalls, ckan, statcan — so the same masking existed there.

I also made the regression test assert the general property rather than enumerate exception types: test_decode_helpers_never_raise_a_valueerror_subclass feeds four malformed-body shapes and asserts nothing escaping the helpers is a ValueError subclass. Enumerating types is what let this through; the property does not have that failure mode.

Non-vacuity confirmed by reverting shared/http.py — all 4 new tests fail.

where = f" from {url}" if url else ""
raise httpx.DecodingError(
f"upstream returned a non-JSON body{where}: {exc}"
) from exc


def decode_json_bytes(content: bytes, url: str = "") -> Any:
"""``decode_json`` for callers holding raw bytes (OGC WFS reads .content)."""
try:
return json.loads(content)
except json.JSONDecodeError as exc:
where = f" from {url}" if url else ""
raise httpx.DecodingError(
f"upstream returned a non-JSON body{where}: {exc}"
) from exc


def is_retryable(exc: BaseException) -> bool:
"""Return True if the exception warrants a retry."""
if isinstance(exc, httpx.HTTPStatusError):
Expand Down Expand Up @@ -54,20 +88,6 @@ async def _fetch() -> Any:
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.get(url, params=params, headers=headers)
response.raise_for_status()
try:
return response.json()
except json.JSONDecodeError as exc:
# An upstream that answers 200 with an HTML error page is an
# upstream failure, but json.JSONDecodeError subclasses
# ValueError and would be caught by the
# `except ValueError -> INVALID_INPUT` arms in statcan, ircc,
# manitoba, saskatchewan, nova_scotia, british_columbia and
# datastore — blaming the caller for someone else's outage.
# httpx.DecodingError is an httpx.HTTPError but NOT a
# ValueError, so it bypasses those arms and lands in the
# catch-all every module now has.
raise httpx.DecodingError(
f"upstream returned a non-JSON body from {url}: {exc}"
) from exc
return decode_json(response, url)

return await _fetch()
8 changes: 4 additions & 4 deletions src/mcp_canada/shared/ogc.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

import httpx

from mcp_canada.shared.http import decode_json_bytes

# Imported at module level; defer inside function only if circular import arises.
from mcp_canada.shared.parsers import _parse_geojson

Expand Down Expand Up @@ -129,9 +131,8 @@ async def wfs_get_features(

# Use response.content (bytes) so _parse_geojson handles JSON parsing internally.
# We also need numberReturned for the has_more flag — parse once via json module.
import json as _json # noqa: PLC0415 — avoids shadowing top-level json if user imports this

body = _json.loads(response.content)
body = decode_json_bytes(response.content)
features = _parse_geojson(response.content, include_geometry=include_geometry)
has_more = body.get("numberReturned", 0) >= count
return features, has_more
Expand Down Expand Up @@ -245,9 +246,8 @@ async def wfs_count(

response.raise_for_status()

import json as _json # noqa: PLC0415

body = _json.loads(response.content)
body = decode_json_bytes(response.content)
return int(body.get("totalFeatures", 0))


Expand Down
7 changes: 4 additions & 3 deletions src/mcp_canada/shared/parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from __future__ import annotations

import csv
import json
import re
import ssl
import unicodedata
Expand All @@ -19,6 +18,8 @@

import httpx

from mcp_canada.shared.http import decode_json_bytes

from mcp_canada.shared.cache import cached_fetch

# Regex to collapse non-alphanumeric runs to underscores
Expand Down Expand Up @@ -443,7 +444,7 @@ def _parse_geojson(
List of dicts, one per Feature. Each dict contains the Feature's
properties. If properties is None, returns an empty dict {}.
"""
data = json.loads(content)
data = decode_json_bytes(content)
features = data.get("features", [])
result: list[dict[str, Any]] = []
for feature in features:
Expand All @@ -469,7 +470,7 @@ def _parse_json(content: bytes) -> list[dict[str, Any]]:
Returns:
List of dicts.
"""
data = json.loads(content)
data = decode_json_bytes(content)
if isinstance(data, list):
return data
if isinstance(data, dict) and "features" in data:
Expand Down
14 changes: 8 additions & 6 deletions src/mcp_canada/shared/socrata.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@

import httpx

from mcp_canada.shared.http import decode_json

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -85,12 +87,12 @@ async def search_catalog(
if httpx_client is not None:
response = await httpx_client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
return decode_json(response, url)

async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
return decode_json(response, url)


async def get_dataset_metadata(
Expand Down Expand Up @@ -125,12 +127,12 @@ async def get_dataset_metadata(
if httpx_client is not None:
response = await httpx_client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
data = decode_json(response, url)
else:
async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, headers=headers)
response.raise_for_status()
data = response.json()
data = decode_json(response, url)

return _flatten_metadata(data)

Expand Down Expand Up @@ -195,12 +197,12 @@ async def query_dataset(
if httpx_client is not None:
response = await httpx_client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
return decode_json(response, url)

async with httpx.AsyncClient(timeout=DEFAULT_TIMEOUT) as client:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
return decode_json(response, url)


def shape_catalog_result(result: dict[str, Any]) -> dict[str, Any]:
Expand Down
Loading
Loading