Skip to content

fix(client): surface non-2xx HTTP responses to the waiting caller#3092

Open
himanshu-commits wants to merge 2 commits into
modelcontextprotocol:v1.xfrom
himanshu-commits:fix/streamable-http-status-error-3091-v1x
Open

fix(client): surface non-2xx HTTP responses to the waiting caller#3092
himanshu-commits wants to merge 2 commits into
modelcontextprotocol:v1.xfrom
himanshu-commits:fix/streamable-http-status-error-3091-v1x

Conversation

@himanshu-commits

@himanshu-commits himanshu-commits commented Jul 13, 2026

Copy link
Copy Markdown

Relates to #3091.

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 — a 500 on one tools/list no 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 / code 32600 — 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 32600 is positive, where JSON-RPC codes are negative (2.0 corrected it to
-32600). I've left it alone — changing it would break anyone checking error.code == 32600
on 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 redirect
falls through to the content-type dispatch rather than the correlated-error path. In practice
create_mcp_http_client sets follow_redirects=True, so this only affects user-supplied
clients with redirects disabled — and 2.0 carries the identical >= 400 guard, so it's
parity, 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.data would 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 via
httpx.MockTransport:

  • 401 / 403 / 500 / 503 each reach the waiting caller. Each is wrapped in anyio.fail_after(5),
    so without the fix they hang rather than pass vacuously — that's the regression pin.
  • 404 still reports Session terminated (guard on the unchanged behaviour)
  • a JSON-RPC error body is preferred, re-stamped with the request's id
  • a JSON body that isn't a JSON-RPC error (proxy/gateway JSON) falls back to the stand-in
  • a non-2xx body that dies mid-read still resolves the caller
  • a non-2xx to a notification resolves nobody and leaves the session alive
  • a failed request leaves the session usable

Full suite passes (1149), 100% branch coverage. Six # pragma: no cover markers removed from
the 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

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

_handle_resumption_request still carries a bare raise_for_status() — the same bug class on
the resumption GET path, unfixed on 2.0 as well. Out of scope here; noting it so it isn't lost.

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

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/mcp/client/streamable_http.py Outdated
Comment thread src/mcp/client/streamable_http.py
`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

@himanshu-commits himanshu-commits left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

done changes

@afterrburn afterrburn left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

  1. 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 → bare ValueError_handle_incoming → the caller hangs until read timeout — the fixed failure mode, on a rarer path. Mitigated in practice: SDK-created clients set follow_redirects=True (_httpx_utils.py), so this only bites user-supplied non-redirecting clients against a redirecting endpoint — and 2.0 has the identical >= 400 guard, so it's parity. Might deserve one line in the PR description, or a >= 300 guard.

  2. Resumption GET path still has the same bug class. _handle_resumption_request keeps its bare event_source.response.raise_for_status(). Also unfixed on 2.0 — fine as a follow-up, just noting it so it doesn't get lost.

  3. 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". data is 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, data seems 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants