Skip to content

bug(client/streamable_http): prevent infinite SSE reconnect loops with bounded exponential backoff #3356

Closed as not planned

Description

@AAH20

Problem

In StreamableHTTPTransport.handle_get_stream, when an SSE stream disconnects or closes (server-side EOF/backpressure), attempt was reset to 0 upon exit. If an upstream server disconnects continuously, the client bypasses MAX_RECONNECTION_ATTEMPTS and enters an infinite reconnection loop without backoff jitter.

Expected Behavior

The client should respect MAX_RECONNECTION_ATTEMPTS on stream disconnects and apply exponential backoff to prevent thundering-herd storms on upstream MCP servers.

Activity

  1. added
    v2Affects the v2 line (2.x on main)
    v1Affects the v1.x maintenance line
    on Aug 21, 2026
  2. Ben-Home commented on Aug 21, 2026

    @Ben-Home

    Confirmed the same failure mode on our side: when the upstream closes the SSE stream (EOF or backpressure), the attempt counter resetting on exit makes MAX_RECONNECTION_ATTEMPTS a no-op for the stream path, and the client keeps hammering the server with no backoff.

    The fix has two parts that should land together:

    1. Attempt accounting on the disconnect path. Do not reset the counter when the stream closes; reset it only when a reconnection has established a healthy stream and received at least one message (or an initialize response, depending on the transport state). The current code resets on exit from handle_get_stream, which is exactly the wrong place. A useful invariant: the counter should survive every exit from the read loop and only be cleared by successful message receipt, not by connection teardown.

    2. Jittered exponential backoff on every reconnect, including the first retry. If the upstream is down, the first reconnect attempt should still wait rather than firing immediately, otherwise a fleet of clients all observe the disconnect at once and reconnect in a thundering herd. Standard pattern: base * 2**attempt with full jitter (random between 0 and the computed delay) and a cap (30-60s) so a long outage does not produce absurd sleeps. The existing MAX_RECONNECTION_ATTEMPTS should bound the loop, and once exhausted the transport should surface a terminal error instead of silently retrying forever.

    For the test: a server that accepts the stream then closes it immediately (or after N events) will reproduce this deterministically. Assert that the client stops after MAX_RECONNECTION_ATTEMPTS and that consecutive attempts are spaced by at least the jittered backoff minimum. That test would have caught this bug because it exercises the exit path rather than a timeout path.

    One thing to watch in the implementation: if the transport is shared by multiple concurrent requests, the attempt counter and backoff state need to live on the transport, not on the request scope, otherwise a single disconnect can reset the state of the whole pool. We hit this exact pattern with pooled upstream connections in our connector runtime at corpusiq.io and the fix that held was per-connection reconnect state with a shared jittered scheduler, never per-request counters.

  3. jagadeepmamidi commented on Sep 1, 2026

    @jagadeepmamidi

    I can reproduce the reconnect-loop behavior described here. I’d be interested in working on a focused fix that keeps the reconnection attempt count bounded across SSE disconnects, applies the existing backoff behavior, and adds regression coverage for repeated disconnects. Happy to work on this if the approach aligns with what you have in mind.

  4. tk1475 commented on Sep 25, 2026

    @tk1475

    I think this is the same bug as #3257. In handle_get_stream (src/mcp/client/streamable_http.py, around L252-253 on main and L310-311 on v1.x), a GET stream that ends without an exception sets attempt = 0. So a server that keeps closing the stream cleanly never pushes the counter toward MAX_RECONNECTION_ATTEMPTS, and the client reconnects forever. #3087 (currently a draft) already targets this by not resetting the counter on empty connections, so it may be worth tracking or reviewing that PR rather than opening a separate fix. @jagadeepmamidi, if you want to help, the regression test you described would probably be a useful addition there.

  5. Kludex commented on Oct 14, 2026

    @Kludex
    Member

    Duplicate of #3257.

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

    v1Affects the v1.x maintenance linev2Affects the v2 line (2.x on main)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions