You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Productive SSE reconnects accumulate stack frames and eventually fail #3791
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.
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.
Activity
media-ctrl commentedon Nov 4, 2026
Your #2393 distinction is right, and the code shows why the retry budget can never bound this.
On current main (af4c6f4),
_handle_reconnectionrecurses on the productive path atstreamable_http.py:596withattemptreset to 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,
RecursionErroris anException, so the handler at:604catches 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
exceptpaths recurse with thelast_event_idparameter, not withreconnect_last_event_idthat is tracked inside thetryblock 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
attemptaccounting 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.