Repository navigation
HTTP transport swallows non-2xx status codes causing client to hang #2110
Description
Activity
- addedbugSomething isn't workingSomething isn't workingv2Affects the v2 line (2.x on main)Affects the v2 line (2.x on main)P1Significant bug affecting many users, highly requested featureSignificant bug affecting many users, highly requested feature
on Feb 19, 2026 Investigation Results
I've systematically investigated this issue and confirmed the root cause. Here's what I found:
Root Cause
In both SSE and StreamableHTTP transports, when HTTP errors occur, exceptions are caught and logged but **never sent to **, causing the caller to hang indefinitely waiting for a response that will never arrive.
Specific Issues Found
1.
sse.py(lines 147-148):except Exception: logger.exception("Error in post_writer") # Missing: await read_stream_writer.send(exc)
response.raise_for_status()at line 145 raisesHTTPStatusErrorfor non-2xx codes- Exception is caught, logged, but never propagated to caller
- Result: Caller hangs forever
2.
streamable_http.py(lines 481-482):except Exception: logger.exception("Error in post_writer") # Missing: await read_stream_writer.send(exc)
- Same issue: exceptions logged but not sent to
read_stream_writer
Evidence
Working transports (websocket, stdio) correctly send exceptions:
# websocket.py (working ✅) except ValidationError as exc: await read_stream_writer.send(exc) # stdio.py (working ✅) except Exception as exc: logger.exception("...") await read_stream_writer.send(exc) # ← Sends exception!
Data Flow Showing the Bug
Caller: await session.send_request() ↓ post_writer: response = await client.post(...) ↓ Server returns 401 ↓ httpx raises HTTPStatusError ↓ post_writer catches exception ↓ logger.exception("Error") ← Only logs! ↓ Function exits ↓ Caller: await response_stream.receive() ← Hangs forever ⏳Test Confirmation
Created reproduction test that confirms:
- When 401 error occurs, NO messages are sent to
read_stream_writer - Exception is only logged, never propagated
- Caller hangs indefinitely
Next Steps
Ready to implement the fix which will:
- Catch
httpx.HTTPStatusErrorspecifically - Convert to
JSONRPCErrorwith HTTP status code preserved - Send to
read_stream_writerso caller receives it
Investigation Results
I have systematically investigated this issue and confirmed the root cause.
Root Cause
In both SSE and StreamableHTTP transports, when HTTP errors occur, exceptions are caught and logged but never sent to the read stream, causing the caller to hang indefinitely waiting for a response that will never arrive.
Specific Issues Found
1.
sse.py(lines 147-148):
Thepost_writerfunction catches exceptions and only logs them:response.raise_for_status()at line 145 raisesHTTPStatusErrorfor non-2xx codes- Exception is caught, logged, but never propagated to caller
- Result: Caller hangs forever
2.
streamable_http.py(lines 481-482):
Same issue - exceptions logged but not sent toread_stream_writerEvidence from Working Transports
Working transports (websocket, stdio) correctly send exceptions to the read stream so the caller can handle them.
Test Confirmation
Created a reproduction test that confirms:
- When HTTP errors occur, NO messages are sent to
read_stream_writer - Exception is only logged, never propagated
- Caller would hang indefinitely
Next Steps
Ready to implement the fix which will:
- Catch
httpx.HTTPStatusErrorspecifically - Convert to
JSONRPCErrorwith HTTP status code preserved - Send to
read_stream_writerso caller receives it
- added a commit that references this issue
on Feb 22, 2026 Will this land pre-v2? It's a pretty significant bug and there is an open PR (or two) with a solution.
@maxisbey your P1 on error-swallowing in streamable transports is the exact gap that kills unattended MCP workflows — non-2xx errors silently cause indefinite client hangs with zero feedback to the caller. The client thinks it is still connected, so downstream tool calls queue up behind a dead transport.
The pattern we see from operators running multi-MCP setups: one transport failure silently poisons the entire session because nothing detects the hang until a human notices hours later. The fix that worked for one team was a transport-level heartbeat that treats "no response for N seconds" as a hard failure rather than waiting for an error that never comes.
Are you seeing operators hit this in production workflows, or is it still mostly a transport-layer correctness concern?
Draft comment for issue #2110
Purpose: claim the issue per CONTRIBUTING.md ("comment on the issue so we can assign it to you"), explain scope tightly, and acknowledge prior PRs so it's clear we're not duplicating.
Post at: #2110
Happy to take this on. I'd like to propose a tight, single-purpose PR with the following scope — want to confirm before opening.
Scope (2 parts, both in this issue):
-
Propagate errors instead of swallowing them. In
sse.py::post_writerandstreamable_http.py::post_writer, narrow the currentexcept Exceptiontoexcept httpx.HTTPError as excandawait read_stream_writer.send(exc)— matching the pattern already used instdio.pyandwebsocket.py. This stops the caller-hangs-indefinitely behavior. -
Preserve HTTP status on mapped errors. In
streamable_http.py::_handle_post_request, the 404 →INVALID_REQUESTand ≥400 →INTERNAL_ERRORmappings currently discard the HTTP status code. Keep the JSON-RPC code mapping (spec-compliant) but put the HTTP status inErrorData.data = {"http_status": <code>}and include it inmessageforINTERNAL_ERROR. Minimal, non-breaking.
Explicitly out of scope for this PR:
- Adding a full typed error hierarchy (that's Introduce typed error classes with metadata #1742 and should stay separate).
- Transport-level heartbeat / liveness detection (worth discussing but orthogonal — a PR on top of this fix).
- Anything in
test_stdio.pyor other flaky tests.
About the existing PRs:
- fix(client): propagate HTTP transport exceptions to caller #2124 is still open but has merge conflicts and hasn't moved since 2026-03-05. It bundled an unrelated test fix, which may be why it stalled. If you'd prefer I coordinate with @gspeter-max to rebase or carry their commits forward, I'm happy to — otherwise I'll open a fresh, conflict-free PR once this scope is confirmed.
- fix: preserve HTTP status code in streamable HTTP client error messages #2173, fix: preserve HTTP status codes in streamable HTTP transport error responses #2176, fix: propagate HTTP errors from transports instead of silently logging #2249, Handle connection errors in StreamableHTTP post_writer #2282 were closed without merge; I'll make sure my approach isn't a rerun of them.
Tests I plan to add:
- 401/403 on POST → caller receives
httpx.HTTPStatusErrorwithinanyio.fail_after(3)(no hang). - 404 on POST (with JSONRPCRequest) → caller receives
JSONRPCErrorwithdata.http_status == 404. - 500 on POST → same shape for
INTERNAL_ERROR. - Network error on POST (simulated via mock transport) → caller receives the
httpx.RequestErrorexception.
OK to proceed? If so, happy to get
ready for worklabel and open the PR in the next couple of days.-
This issue is causing significant problems because the transport swallows the error, our clients hang indefinitely, which effectively "poisons" the session and requires manual restarts. Given that PR #2483 specifically addresses the root cause by propagating httpx errors and preserving status codes, could we get a maintainer review to fast-track this into the next release? This is a critical blocker for unattended operations.
Summary
When an MCP server returns non-2xx HTTP status codes (401/403/404/5xx), the Streamable HTTP and SSE transports do not reliably propagate the error to the caller. In the Streamable HTTP transport,
post_writercatches exceptions and logs them, but the caller blocks indefinitely waiting for a response on the read stream. HTTP 404 responses are also converted toJSONRPCError(code=32600), losing the original HTTP status information.Expected Behavior
Current Behavior
post_writerin Streamable HTTP transport catches and logs errors without forwarding them through the read streamJSONRPCError(code=32600), destroying HTTP-level contextAffected Code
src/mcp/client/streamable_http.py(post_writer)src/mcp/client/sse.py(sse_reader)Related