Skip to content

Productive SSE reconnects accumulate stack frames and eventually fail #3791

Open

Description

@namtran1812

Problem

Repeated productive SSE resumptions eventually fail because _handle_reconnection() recursively awaits itself.

I reproduced this on current main at d7845c07, using an in-memory HTTP transport. Every connection delivers a notification with a new event ID before closing; connection 1,500 supplies the final JSON-RPC response.

On macOS with Python 3.13:

  • Default recursion limit: 972 HTTP requests, 970 delivered notifications, then CONNECTION_CLOSED with “reconnection attempts were exhausted.”
  • Recursion limit raised to 3,000: all 1,500 requests complete, delivering 1,499 notifications and the final response.

The higher recursion limit is only a diagnostic experiment.

Expected behavior

Productive resumptions should continue without accumulating Python stack frames or failing because of the interpreter recursion limit.

Scope

This differs from #2393’s no-progress retry-budget problem: every reconnect here delivers new data and advances the event ID.

PR #3790 contains a proposed iterative implementation and a deterministic regression test. The test fails on unmodified code and passes with the patch, without changing the recursion limit. The proposed change preserves existing retry accounting.

The reproduction uses a synthetic peer; I have not verified this failure in a production deployment.

Would an iterative implementation preserving the current retry semantics be an acceptable scope for this issue?

AI assistance was used for investigation and implementation. I reproduced the behavior and ran the transport tests and static checks locally.

Activity

  1. added
    bugSomething isn't working
    v1Affects the v1.x maintenance line
    v2Affects the v2 line (2.x on main)
    on Oct 31, 2026
  2. media-ctrl commented on Nov 4, 2026

    @media-ctrl

    Your #2393 distinction is right, and the code shows why the retry budget can never bound this.

    On current main (af4c6f4), _handle_reconnection recurses on the productive path at streamable_http.py:596 with attempt reset to 0:

    await self._handle_reconnection(ctx, reconnect_last_event_id, reconnect_retry_ms, 0)
    

    So every resume that delivers data restarts the accounting, MAX_RECONNECTION_ATTEMPTS (2) is never reached, and the only remaining bound is the interpreter recursion limit. That is the mechanism behind your 972 versus 1500 numbers.

    There is a second effect worth naming, because it explains the misleading symptom. When the stack finally overflows, RecursionError is an Exception, so the handler at :604 catches it and calls _handle_reconnection(..., attempt + 1) instead of propagating. The failure surfaces as "reconnection attempts were exhausted" rather than as a recursion error, which sends readers looking at the retry budget when the budget was never the constraint. Worth a comment in the code either way.

    One more thing to check while you are in there: the except paths recurse with the last_event_id parameter, not with reconnect_last_event_id that is tracked inside the try block at :580. If a connection fails after processing several events, the resume restarts from the older id and the server can redeliver notifications that were already consumed. That may be intentional for at-least-once semantics, but it is not stated, and it interacts with any dedupe the caller does.

    On scope: an iterative loop is the right shape precisely because the productive path is unbounded by design. Keep attempt accounting exactly as it is for the error paths, and only the successful resume returns to the top of the loop. For the regression test, you do not need 1,500 connections: sys.setrecursionlimit(200) reproduces the same failure in 200 requests and makes the test fast enough to keep in CI, with the unmodified code failing and the loop passing.

    On the PR: #3790 was closed by the require-linked-issue bot, not on merit. It reopens on its own if a maintainer assigns you to this issue, so asking for assignment is a shorter path than opening a replacement.

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

    bugSomething isn't workingv1Affects 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