Bound the total wait across SSE reconnection attempts - #504
Open
koic wants to merge 1 commit into
Open
Conversation
koic
force-pushed
the
bound_sse_reconnection_delay
branch
3 times, most recently
from
August 10, 2026 20:03
e4c904d to
bc94960
Compare
## Motivation and Context The SSE `retry:` field is chosen by the server, and `MCP::Client::HTTP` passed it to `sleep` unchanged in both reconnection paths. `await_response_after_disconnect` runs on the calling thread, so a server that primes a stream with an event id, closes it gracefully, and asks for a large `retry:` interval keeps that thread waiting for as long as it likes. `MAX_RECONNECTION_ATTEMPTS` caps how many times the client reconnects, not how long it waits before each attempt, and no I/O timeout covers a sleep. The obvious fix, clamping `retry:` to a ceiling, is not available: the spec says a client "MUST respect the `retry` field, waiting the given number of milliseconds before attempting to reconnect", and reconnecting sooner than a server asked for is also the wrong direction to err in, since `retry:` is how an overloaded server asks for room. Both reference SDKs pass the value through untouched. What the spec does allow is waiting longer. The `retry` field it points at is the one WHATWG HTML defines, whose reconnection algorithm reads "Wait a delay equal to the reconnection time of the event source. Optionally, wait some more." It also leaves reconnecting at all a SHOULD, which this client already declines once `MAX_RECONNECTION_ATTEMPTS` is reached. Both fixes below sit inside that room; neither shortens a wait. `await_response_after_disconnect` now carries a deadline, `max_reconnection_wait:` seconds (`MAX_RECONNECTION_WAIT`, 300, the same budget as `SSE_LISTENER_READ_TIMEOUT`). Before each attempt it compares the delay the server asked for against the remaining budget, and stops reconnecting when honoring it in full would run past the deadline. The server's interval is therefore either waited out exactly or not acted on at all, and the calling thread is released as soon as the answer is known. On the reported case, a day-long `retry:`, the thread now returns without sleeping at all. Whatever is left of the budget also becomes the resumed stream's read timeout, so the bound covers a server that accepts the GET and then sends nothing. That GET previously relied on whatever the Faraday adapter defaults to, which is 60 seconds for Net::HTTP but need not exist at all for a caller-supplied adapter. `listen_for_server_requests` already guarded its own GET this way with `SSE_LISTENER_READ_TIMEOUT`. The other path has the opposite problem. `listen_for_server_requests` treats a graceful close as success and resets `consecutive_failures`, so `retry: 0` never reaches the attempt cap and the listening stream reconnects in a tight loop. A `MIN_RECONNECTION_DELAY_MS` floor of 100ms stops that, and is the "optionally, wait some more" case exactly. That path gets no deadline: it runs on a thread this client owns and is meant to poll indefinitely (the spec asks clients to "poll" a closed stream by reconnecting), so a long `retry:` there idles the SDK's own listener rather than the embedding application. `DEFAULT_RECONNECTION_DELAY_MS` and `MAX_RECONNECTION_ATTEMPTS` are unchanged and still match the Python SDK. ## How Has This Been Tested? New tests in `test/mcp/client/http_test.rb` cover a day-long `retry:` releasing the calling thread without sleeping, a delay that fits the budget still being waited out in full and resuming normally, a caller-supplied `max_reconnection_wait:` stopping the resume with a message naming the budget, and the argument validation. Both reconnection paths are covered for the unusable values a server can send: `retry: 0` is raised to the floor on the resumption path and on the listening stream, and negative or non-numeric values (`-5000`, `abc`, `1e6`, `500ms`) fall back to the default delay, since the SSE parser accepts only a run of digits. The existing reconnection tests, whose `retry:` values fit the default budget, are unaffected. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. The conformance `sse-retry` scenario scores `client-sse-retry-timing`, which checks the reconnect happens neither early nor very late against a 500ms `retry:`; since no wait is ever shortened, that check is unaffected by the size of the value it uses. ## Breaking Changes A request whose resumption cannot complete within `max_reconnection_wait:` (300 seconds by default) now fails instead of waiting however long the server asked for. Servers asking for intervals that fit the budget are unaffected. Pass a larger `max_reconnection_wait:` if you expect longer.
koic
force-pushed
the
bound_sse_reconnection_delay
branch
from
August 10, 2026 20:29
bc94960 to
248b5f8
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
The SSE
retry:field is chosen by the server, andMCP::Client::HTTPpassed it tosleepunchanged in both reconnection paths.await_response_after_disconnectruns on the calling thread, so a server that primes a stream with an event id, closes it gracefully, and asks for a largeretry:interval keeps that thread waiting for as long as it likes.MAX_RECONNECTION_ATTEMPTScaps how many times the client reconnects, not how long it waits before each attempt, and no I/O timeout covers a sleep.The obvious fix, clamping
retry:to a ceiling, is not available: the spec says a client "MUST respect theretryfield, waiting the given number of milliseconds before attempting to reconnect", and reconnecting sooner than a server asked for is also the wrong direction to err in, sinceretry:is how an overloaded server asks for room. Both reference SDKs pass the value through untouched.What the spec does allow is waiting longer. The
retryfield it points at is the one WHATWG HTML defines, whose reconnection algorithm reads "Wait a delay equal to the reconnection time of the event source. Optionally, wait some more." It also leaves reconnecting at all a SHOULD, which this client already declines onceMAX_RECONNECTION_ATTEMPTSis reached. Both fixes below sit inside that room; neither shortens a wait.await_response_after_disconnectnow carries a deadline,max_reconnection_wait:seconds (MAX_RECONNECTION_WAIT, 300, the same budget asSSE_LISTENER_READ_TIMEOUT). Before each attempt it compares the delay the server asked for against the remaining budget, and stops reconnecting when honoring it in full would run past the deadline. The server's interval is therefore either waited out exactly or not acted on at all, and the calling thread is released as soon as the answer is known. On the reported case, a day-longretry:, the thread now returns without sleeping at all.Whatever is left of the budget also becomes the resumed stream's read timeout, so the bound covers a server that accepts the GET and then sends nothing. That GET previously relied on whatever the Faraday adapter defaults to, which is 60 seconds for Net::HTTP but need not exist at all for a caller-supplied adapter.
listen_for_server_requestsalready guarded its own GET this way withSSE_LISTENER_READ_TIMEOUT.The other path has the opposite problem.
listen_for_server_requeststreats a graceful close as success and resetsconsecutive_failures, soretry: 0never reaches the attempt cap and the listening stream reconnects in a tight loop. AMIN_RECONNECTION_DELAY_MSfloor of 100ms stops that, and is the "optionally, wait some more" case exactly. That path gets no deadline: it runs on a thread this client owns and is meant to poll indefinitely (the spec asks clients to "poll" a closed stream by reconnecting), so a longretry:there idles the SDK's own listener rather than the embedding application.DEFAULT_RECONNECTION_DELAY_MSandMAX_RECONNECTION_ATTEMPTSare unchanged and still match the Python SDK.How Has This Been Tested?
New tests in
test/mcp/client/http_test.rbcover a day-longretry:releasing the calling thread without sleeping, a delay that fits the budget still being waited out in full and resuming normally, a caller-suppliedmax_reconnection_wait:stopping the resume with a message naming the budget, and the argument validation. Both reconnection paths are covered for the unusable values a server can send:retry: 0is raised to the floor on the resumption path and on the listening stream, and negative or non-numeric values (-5000,abc,1e6,500ms) fall back to the default delay, since the SSE parser accepts only a run of digits. The existing reconnection tests, whoseretry:values fit the default budget, are unaffected.bundle exec rake(tests, RuboCop, and conformance baseline) passes. The conformancesse-retryscenario scoresclient-sse-retry-timing, which checks the reconnect happens neither early nor very late against a 500msretry:; since no wait is ever shortened, that check is unaffected by the size of the value it uses.Breaking Changes
A request whose resumption cannot complete within
max_reconnection_wait:(300 seconds by default) now fails instead of waiting however long the server asked for. Servers asking for intervals that fit the budget are unaffected. Pass a largermax_reconnection_wait:if you expect longer.Types of changes
Checklist