Skip to content

streamable_http_client: one concurrent request HTTPStatusError tears down sibling requests #2604

Closed

Description

@Jarda8

Initial Checks

Description

When using streamable_http_client with concurrent JSON-RPC requests over the same Streamable HTTP session, an HTTP error from one request can escape the transport task group and disrupt unrelated sibling requests that are still waiting for their responses.

This happens because post_writer() starts each JSON-RPC request as a task: tg.start_soon(handle_request_async) and _handle_post_request() calls: response.raise_for_status()

If one concurrent request receives a non-2xx HTTP response, e.g. 400 Bad Request, the resulting httpx.HTTPStatusError escapes from that per-request task into the shared task group. The task group then exits with:
ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)

At the same time, sibling requests that were still waiting in ClientSession.send_request() can be cancelled/disrupted. In our production traces, the sibling request stack showed:

File ".../mcp/shared/session.py", line 292, in send_request
  response_or_error = await response_stream_reader.receive()

File ".../anyio/streams/memory.py", line 117, in receive
  return self.receive_nowait()

File ".../anyio/streams/memory.py", line 112, in receive_nowait
  raise WouldBlock

anyio.WouldBlock

The WouldBlock is not the root cause by itself; it appears because the sibling request was waiting for a response when the transport/session was torn down by the other request's HTTP error.

Expected Behavior

A non-2xx HTTP response for one request should be scoped to that request only.

For example, if request A receives HTTP 400, request A should receive a request-level exception or MCP error, but request B should continue waiting for its own response and should be able to complete successfully.

Actual Behavior

A single request's HTTPStatusError escapes the shared transport task group and can poison the whole Streamable HTTP session, cancelling or disrupting sibling requests.

Why this matters

Parallel tool calls are a common and useful pattern for MCP clients. In our agent, we intentionally allow parallel MCP tool calls to reduce latency. If one tool call gets a server-side HTTP 400 while another valid tool call is still in flight, the valid sibling call can fail too.

Related Issues

This seems related to but more specific than:

#2110
#1805

Those discuss non-2xx propagation and stream cleanup races. This issue is specifically about concurrent Streamable HTTP requests where one request's HTTP error escapes the per-request boundary and tears down sibling in-flight requests.

Example Code

Python & MCP Python SDK

Python 3.12
MCP Python SDK 1.26.0

I also inspected 1.27.1, which is currently the latest PyPI release, and the same relevant logic appears to still be present.

Activity

  1. jshaofa-ui commented on May 15, 2026

    @jshaofa-ui
  2. jshaofa-ui commented on May 15, 2026

    @jshaofa-ui
  3. added
    triageQueued for automated analysis — bot will process and remove this label
    on May 31, 2026
  4. ghost added
    potentially closeBot recommends closing — needs maintainer review
    on Jun 1, 2026
  5. ghost removed
    triageQueued for automated analysis — bot will process and remove this label
    on Jun 1, 2026
  6. norika1207-lab commented on Jun 2, 2026

    @norika1207-lab
  7. maxisbey commented on Jun 11, 2026

    @maxisbey
    Contributor

    This is fixed on main by #2047, non-2xx responses get turned into a request-scoped JSON-RPC error instead of raising into the task group, so a 400 on one request no longer kills its siblings. Verified your concurrent scenario against current main and the sibling request completes fine.

    https://github.com/modelcontextprotocol/python-sdk/blob/616476fc4f4a35ee3b1611548e80ccc0858d142e/src/mcp/client/streamable_http.py#L277-L282

    We're not backporting it to v1.x. Any fix here changes what you get on a 4xx/5xx (from ExceptionGroup wrapping httpx.HTTPStatusError to an McpError), and there's downstream code on 1.x that relies on the current shape for retry/auth handling. Not a change we want to make in a point release, so it lands with v2.

    Closing as fixed on main.

    AI Disclaimer

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    potentially closeBot recommends closing — needs maintainer review

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions