fix(client): surface non-2xx HTTP responses to the waiting caller#3092
Conversation
A 4xx/5xx response to a message POST hit a bare `response.raise_for_status()` inside the POST's background task. Nothing caught the exception and nothing resolved the request, so the caller sat until its read timeout fired: a 401 was indistinguishable from a slow server, and the escaping error tore down the transport's task group with it. Non-2xx statuses are now answered rather than raised, generalising the handling 404 already had: a `JSONRPCError` stamped with the original request's id, sent as a `SessionMessage` over the read stream, so it resolves through the normal response-correlation path. A spec-compliant JSON-RPC error in the body is preferred over the status-derived stand-in. The connection stays usable. This matches the behaviour already shipped on the 2.0 line, with one deliberate difference: 404 is answered ahead of the body check. A terminated session 404s *with* a JSON-RPC error body, so preferring it would replace this line's `Session terminated` / code 32600 — which callers match on for session expiry — with the server's own wording. Callers still cannot tell a terminal 401 from a retryable 503; both remain `-32603 Server returned an error response`, as on 2.0. Carrying the status would be a follow-up on the 2.0 line. Github-Issue: modelcontextprotocol#3091 Reported-by: afterrburn
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
`httpx.StreamError` derives from `RuntimeError`, not `httpx.HTTPError`, so it does not cover a body that stalls or truncates while being read: `aread()` raises `ReadTimeout`/`RemoteProtocolError` there. Those escaped into the POST's background task and stranded the caller — the bug this path exists to prevent. Github-Issue: modelcontextprotocol#3091
There was a problem hiding this comment.
The core mechanism is exactly right. Non-2xx now produces a JSONRPCError stamped with the original request's id, sent as a SessionMessage — which routes through _handle_response and resolves the specific _response_streams[request_id] future the caller is awaiting.
Suggestions (minor — none of these blocking)
-
3xx edge case.
raise_for_status()used to raise for anything non-2xx; the new guard is>= 400. A 3xx now falls through to the content-type dispatch → for a request, likely_handle_unexpected_content_type→ bareValueError→_handle_incoming→ the caller hangs until read timeout — the fixed failure mode, on a rarer path. Mitigated in practice: SDK-created clients setfollow_redirects=True(_httpx_utils.py), so this only bites user-supplied non-redirecting clients against a redirecting endpoint — and 2.0 has the identical>= 400guard, so it's parity. Might deserve one line in the PR description, or a>= 300guard. -
Resumption GET path still has the same bug class.
_handle_resumption_requestkeeps its bareevent_source.response.raise_for_status(). Also unfixed on 2.0 — fine as a follow-up, just noting it so it doesn't get lost. -
Consider carrying the HTTP status in
ErrorData.data. The description already notes callers can't tell a terminal 401 from a retryable 503 — both land as-32603 "Server returned an error response".datais free-form per the JSON-RPC spec, so e.g.data={"http_status": 401}would be fully backward-compatible and would let clients fail fast on auth failures vs. retry on 5xx. Speaking as a downstream consumer (multi-tenant Temporal SDK wrapping this client): this distinction is exactly what we need for retry classification, so if the deferred "carry the status" follow-up happens,dataseems like the natural spot on both lines.
Nice work — the compat reasoning around 404 and the HTTPError-vs-StreamError distinction on the body read are exactly the kind of care this path needed.
Relates to #3091.
A 4xx/5xx response to a message POST hit a bare
response.raise_for_status()inside thePOST's background task. Nothing caught the exception and nothing resolved the request, so
the caller sat until its read timeout fired: a 401 was indistinguishable from a slow
server, and the escaping error tore down the transport's task group with it.
Non-2xx statuses are now answered rather than raised, generalising the handling 404 already
had: a
JSONRPCErrorstamped with the original request's id, sent as aSessionMessageover the read stream, so it resolves through the normal response-correlation path. A
spec-compliant JSON-RPC error in the body is preferred over the status-derived stand-in.
The connection stays usable — a 500 on one
tools/listno longer kills the next call.Motivation and Context
Reported in #3091: HTTP status errors never reached the caller, and were indistinguishable
from a timeout. This is fixed on the 2.0 line already; per the discussion on the issue, this
PR backports the equivalent behaviour to v1.x, where the bug still reproduces (1.25.0 /
1.28.1).
One deliberate difference from 2.0
2.0 checks the response body before the 404 fallback; this PR checks 404 first.
A terminated session 404s with a JSON-RPC error body, so preferring it would replace this
line's
Session terminated/ code32600— which callers match on for session expiry —with the server's own wording. Two existing tests caught this. Behaviour is identical to 2.0
for every status this issue is actually about.
Related: that
32600is positive, where JSON-RPC codes are negative (2.0 corrected it to-32600). I've left it alone — changing it would break anyone checkingerror.code == 32600on a stable release. Happy to change it if you'd rather.
Note on 3xx
raise_for_status()used to raise for any non-2xx; the new guard is>= 400, so a redirectfalls through to the content-type dispatch rather than the correlated-error path. In practice
create_mcp_http_clientsetsfollow_redirects=True, so this only affects user-suppliedclients with redirects disabled — and 2.0 carries the identical
>= 400guard, so it'sparity, not a regression. Tracked separately rather than widened here.
What this does not fix
Callers still cannot tell a terminal 401 from a retryable 503: both arrive as
-32603 Server returned an error response, exactly as on 2.0. The issue's stated impact(retry policy) therefore isn't fully addressed here. Carrying the originating status on
error.datawould close that, but belongs on the 2.0 line first — happy to follow up.How Has This Been Tested?
Ten new tests in
tests/shared/test_streamable_http.py, driven in-memory viahttpx.MockTransport:anyio.fail_after(5),so without the fix they hang rather than pass vacuously — that's the regression pin.
Session terminated(guard on the unchanged behaviour)Full suite passes (1149), 100% branch coverage. Six
# pragma: no covermarkers removed fromthe 404 path, which was previously untested and is now exercised.
Breaking Changes
None. 404 handling is preserved byte-for-byte, including its code and message. Every other
non-2xx previously raised an exception that escaped the transport; it now resolves the caller
instead.
Types of changes
Checklist
Additional context
_handle_resumption_requeststill carries a bareraise_for_status()— the same bug class onthe resumption GET path, unfixed on 2.0 as well. Out of scope here; noting it so it isn't lost.