Skip to content

Bound the total wait across SSE reconnection attempts - #504

Open
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:bound_sse_reconnection_delay
Open

Bound the total wait across SSE reconnection attempts#504
koic wants to merge 1 commit into
modelcontextprotocol:mainfrom
koic:bound_sse_reconnection_delay

Conversation

@koic

@koic koic commented Aug 10, 2026

Copy link
Copy Markdown
Member

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.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

@koic koic changed the title Bound the Server-Provided SSE Reconnection Delay Bound the server-provided SSE reconnection delay Aug 10, 2026
@koic
koic force-pushed the bound_sse_reconnection_delay branch 3 times, most recently from e4c904d to bc94960 Compare August 10, 2026 20:03
## 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
koic force-pushed the bound_sse_reconnection_delay branch from bc94960 to 248b5f8 Compare August 10, 2026 20:29
@koic koic changed the title Bound the server-provided SSE reconnection delay Bound the total wait across SSE reconnection attempts Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant