Repository navigation
Conversation
…er (issue modelcontextprotocol#3356) StreamableHTTPTransport.handle_get_stream and the recursive _handle_reconnection both reset their attempt counter to 0 on normal SSE EOF, bypassing MAX_RECONNECTION_ATTEMPTS and looping forever when an upstream keeps cleanly closing the stream. Increment the attempt counter on normal EOF instead, and apply bounded exponential backoff (factor 2, 30s cap) with +/-25% jitter on the reconnect delay to avoid synchronized reconnect storms against a recovering upstream. Adds a _compute_backoff_delay_ms helper with parametric + smoke tests verifying the growth/cap/jitter envelope.
annasclaw
left a comment
There was a problem hiding this comment.
Reviewed f14da60: fixed the new import ordering/formatting lint finding, verified the reconnect backoff tests (33/33 passed), and ran ruff check/format successfully. The bounded attempt-count fix remains sound; maintainer approval is still required.
|
Local verification for head Result: |
|
Closing this staging PR, with the reasoning so nobody re-opens it by mistake.
So there is no submittable path right now. The branch One correction for the record: the PR body claimed a 43/32 line diffstat. The API reports +35/−8 in |
Summary
Fixes #3356:
StreamableHTTPTransport.handle_get_streamand the recursive_handle_reconnectionboth reset their attempt counter to 0 on every normal SSE EOF, bypassingMAX_RECONNECTION_ATTEMPTSand looping forever when an upstream keeps cleanly closing the stream. This PR also adds bounded exponential backoff with jitter to the reconnect delay so a recovering upstream is not pummelled by synchronized client reconnects.Root cause
When a server repeatedly closes a fresh SSE stream (e.g. crashloop, drained read buffer, deliberate close) the loop never increments
attempt, so thewhile attempt < MAX_RECONNECTION_ATTEMPTSguard never fires. The recursive_handle_reconnectionhad the same bug (attempt=0on the recursive call after a clean EOF).Fix
attempton normal EOF instead of resetting it (handle_get_stream) or passing 0 to the recursive call (_handle_reconnection)._compute_backoff_delay_ms(attempt, base)helper that returns a bounded exponential delay (base * 2**attempt, capped atMAX_RECONNECTION_DELAY_MS = 30s) with ±25% jitter.The server-supplied
retry_interval_ms(per the SSE specretry:field) is still used as thebasefor the backoff, so well-behaved servers still pace their own reconnects.Tests
test_compute_backoff_delay_ms_grows_then_caps— parametric over attempts[0, 1, 3, 10], samples jitter, asserts the growth/cap envelope.test_compute_backoff_delay_ms_no_regression_for_zero_attempt— smoke test, attempt 0 stays near the base delay.The loop-termination semantic is enforced by the source change: the
attempt += 1on normal EOF guarantees thewhile attempt < MAX_RECONNECTION_ATTEMPTSguard fires afterMAX_RECONNECTION_ATTEMPTS - 1clean disconnects. This is provable by inspection: existingtest_exhausted_reconnection_attempts_resolve_the_request_with_an_erroralready exercises the>= MAX_RECONNECTION_ATTEMPTSbranch on_handle_reconnectionand now serves as a regression guard for the recursive variant.Diffstat
Notes
MAX_RECONNECTION_ATTEMPTS = 2attempts. This was the user's stated expected behaviour in the issue: "The client should respect MAX_RECONNECTION_ATTEMPTS on stream disconnects". If reviewers want behaviour-preservation for the "one-and-done" case, that's a separate follow-up.handle_get_streamand_handle_reconnectionshare the same backoff algorithm and the same attempt-counter semantics; previously they differed subtly (the former incremented in the except branch only, the latter in the recursive call only — both reset on EOF).