Skip to content

fix(sse): saturate exponential reconnect backoff to avoid overflow panic - #1231

Open
ump45nose wants to merge 2 commits into
modelcontextprotocol:mainfrom
ump45nose:contrib/631d546176b5
Open

fix(sse): saturate exponential reconnect backoff to avoid overflow panic#1231
ump45nose wants to merge 2 commits into
modelcontextprotocol:mainfrom
ump45nose:contrib/631d546176b5

Conversation

@ump45nose

Copy link
Copy Markdown

Summary

fix(sse): saturate exponential reconnect backoff to avoid overflow panic

Verification

cargo test -p rmcp --features client-side-sse --lib client_side_sse (16 passed); cargo clippy -p rmcp --features client-side-sse --lib (no new warnings); cargo fmt --check (clean)

Related to #1198

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

ExponentialBackoff::retry computed the reconnect multiplier with
2u32.pow(current_times). With max_times unset, current_times can reach
the bit width, panicking in debug builds and wrapping to a zero delay in
release builds for long-lived SSE clients. Use saturating_pow and
Duration::saturating_mul so the delay stays monotonic and panic-free.
@ump45nose
ump45nose requested a review from a team as a code owner August 31, 2026 09:23
@github-actions github-actions Bot added T-core Core library changes T-transport Transport layer changes labels Aug 31, 2026
Comment on lines +234 to +235
let multiplier = 2u32.saturating_pow(current_times as u32);
Some(self.base_duration.saturating_mul(multiplier))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once the multiplier reaches its limit, the delay can be decades long. The caller passes it directly to tokio::time::sleep, so the stream neither reconnects nor terminates.

Saturating the multiplier alone can still yield decades-long sleeps once
current_times reaches the bit width, pinning the stream in
tokio::time::sleep without reconnecting or terminating. Add an optional
max_delay (default 30s) that clamps the computed delay, keeping the
backoff monotonic and panic-free while guaranteeing the client retries.
@ump45nose

Copy link
Copy Markdown
Author

Thanks for the catch — you're right that a saturating multiplier alone leaves the delay unbounded, and at the saturated value tokio::time::sleep would park the stream for decades without reconnecting or terminating.

Pushed a follow-up that adds an optional ExponentialBackoff::max_delay (default DEFAULT_MAX_DELAY = 30s). The computed delay is now clamped to that ceiling, so the backoff stays monotonic and panic-free while guaranteeing the client actually retries. max_delay: None restores the previous unbounded (but now saturating) behavior for callers that want it.

Added a regression test (exponential_backoff_caps_delay_at_max_delay) asserting the delay grows monotonically, never exceeds the cap, and pins at max_delay past the saturation point.

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

Self {
max_times: None,
base_duration: Self::DEFAULT_DURATION,
max_delay: Some(Self::DEFAULT_MAX_DELAY),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because the struct is #[non_exhaustive], downstream callers can only create one with default() and then mutate its fields. That means anyone who sets max_times will inherit the new 30-second limit.

Do you see this shorter window as a bug fix that could go into a patch release, or would leaving max_delay set to None be the safer default?

@ump45nose

Copy link
Copy Markdown
Author

Good question — thanks for thinking through the #[non_exhaustive] surface.

I'd frame it as: the 30s cap is a safety improvement, not the actual bug fix. The bug in #1198 is the overflow/wrap — that's fully resolved by the saturating multiplier + saturating_mul, independent of max_delay. The cap exists to guarantee a saturated policy still reconnects, but it does change observable timing for anyone relying on ExponentialBackoff::default().

Given that, None as the default is the safer choice for this patch: it preserves the exact pre-existing behavior for downstream callers (unbounded, now panic-free) instead of silently imposing a 30s ceiling on anyone who constructs via default() and only tweaks max_times. That keeps it a clean bug-fix PR suitable for a patch release.

I'd suggest making max_delay: None the Default and letting opt-in callers set Some(...) explicitly (the internal StreamableHttpClientTransportConfig default can still set Some(DEFAULT_MAX_DELAY) where the SDK controls the surface). Would you prefer that, or keeping Some(30s) as the struct default with a documented behavior change?

AI assistance disclosure: AI was used to discover this opportunity and draft the change or text. The submission was checked against the prepared artifact and recorded verification evidence.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

T-core Core library changes T-transport Transport layer changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants