Skip to content

Commit 4951aa6

Browse files
committed
fix(rmcp-client): rebuild transport when retries exhaust
1 parent dc1de83 commit 4951aa6

1 file changed

Lines changed: 66 additions & 14 deletions

File tree

code-rs/rmcp-client/src/rmcp_client.rs

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ use reqwest::Error as ReqwestError;
3131
use tokio::io::AsyncBufReadExt;
3232
use tokio::io::BufReader;
3333
use tokio::process::Command;
34+
35+
const INITIALIZE_RETRY_BASE_DELAY_MS: u64 = 200;
36+
const INITIALIZE_RETRY_MAX_DELAY_MS: u64 = 1_600;
37+
const INITIALIZE_MAX_RETRIES: usize = 3;
3438
use tokio::sync::Mutex;
3539
use tokio::time;
3640
use tracing::info;
@@ -183,9 +187,9 @@ impl RmcpClient {
183187
Ok(service) => break service,
184188
Err(err) => {
185189
let err = annotate_phase_error(Phase::Initialize, err);
186-
if should_retry_initialize(&err, attempt) {
190+
if let Some(delay) = retry_delay_for_initialize(&err, attempt) {
187191
attempt += 1;
188-
time::sleep(Duration::from_millis(250)).await;
192+
time::sleep(delay).await;
189193
transport = build_streamable_http_transport(&url, bearer_token.as_deref());
190194
continue;
191195
}
@@ -299,12 +303,12 @@ fn annotate_phase_error(phase: Phase, err: anyhow::Error) -> anyhow::Error {
299303
err.context(format!("phase={}", phase.as_str()))
300304
}
301305

302-
fn should_retry_initialize(err: &anyhow::Error, attempt: usize) -> bool {
303-
if attempt != 0 {
304-
return false;
306+
fn retry_delay_for_initialize(err: &anyhow::Error, attempt: usize) -> Option<Duration> {
307+
if attempt >= INITIALIZE_MAX_RETRIES {
308+
return None;
305309
}
306310

307-
for source in err.chain() {
311+
let retryable = err.chain().any(|source| {
308312
if let Some(reqwest_err) = source.downcast_ref::<ReqwestError>() {
309313
if reqwest_err.is_timeout() || reqwest_err.is_connect() {
310314
return true;
@@ -314,14 +318,32 @@ fn should_retry_initialize(err: &anyhow::Error, attempt: usize) -> bool {
314318
if let Some(io_err) = source.downcast_ref::<io::Error>() {
315319
if matches!(
316320
io_err.kind(),
317-
io::ErrorKind::TimedOut | io::ErrorKind::ConnectionRefused
321+
io::ErrorKind::TimedOut
322+
| io::ErrorKind::ConnectionRefused
323+
| io::ErrorKind::ConnectionReset
324+
| io::ErrorKind::BrokenPipe
325+
| io::ErrorKind::NotConnected
326+
| io::ErrorKind::WouldBlock,
318327
) {
319328
return true;
320329
}
321330
}
331+
332+
source.downcast_ref::<HandshakeTimeoutError>().is_some()
333+
});
334+
335+
if retryable {
336+
Some(initialize_retry_delay(attempt))
337+
} else {
338+
None
322339
}
340+
}
323341

324-
false
342+
fn initialize_retry_delay(attempt: usize) -> Duration {
343+
let capped_attempt = attempt.min(4);
344+
let multiplier = 1u64 << capped_attempt;
345+
let delay = INITIALIZE_RETRY_BASE_DELAY_MS.saturating_mul(multiplier);
346+
Duration::from_millis(delay.min(INITIALIZE_RETRY_MAX_DELAY_MS))
325347
}
326348

327349
fn build_streamable_http_transport(
@@ -365,6 +387,7 @@ impl StdError for HandshakeTimeoutError {}
365387
mod tests {
366388
use super::*;
367389
use anyhow::anyhow;
390+
use std::time::Duration;
368391

369392
#[test]
370393
fn mcp_schema_version_is_well_formed() {
@@ -388,12 +411,41 @@ mod tests {
388411
}
389412

390413
#[test]
391-
fn should_retry_initialize_detects_transient_errors() {
392-
let timeout_err = anyhow!(io::Error::new(io::ErrorKind::TimedOut, "timed out"));
393-
assert!(should_retry_initialize(&timeout_err, 0));
394-
assert!(!should_retry_initialize(&timeout_err, 1));
414+
fn retry_delay_for_initialize_detects_transient_errors() {
415+
let timeout_err = annotate_phase_error(
416+
Phase::Initialize,
417+
anyhow!(io::Error::new(io::ErrorKind::TimedOut, "timed out")),
418+
);
419+
assert_eq!(
420+
retry_delay_for_initialize(&timeout_err, 0),
421+
Some(Duration::from_millis(INITIALIZE_RETRY_BASE_DELAY_MS))
422+
);
423+
assert_eq!(retry_delay_for_initialize(&timeout_err, INITIALIZE_MAX_RETRIES), None);
424+
425+
let mismatch_err = annotate_phase_error(Phase::Initialize, anyhow!("protocol mismatch"));
426+
assert_eq!(retry_delay_for_initialize(&mismatch_err, 0), None);
427+
}
428+
429+
#[test]
430+
fn retry_delay_handles_handshake_timeout() {
431+
let err = annotate_phase_error(
432+
Phase::Initialize,
433+
handshake_timeout_error(Duration::from_secs(1)),
434+
);
435+
assert!(retry_delay_for_initialize(&err, 0).is_some());
436+
}
395437

396-
let mismatch_err = anyhow!("protocol mismatch");
397-
assert!(!should_retry_initialize(&mismatch_err, 0));
438+
#[test]
439+
fn initialize_retry_delay_exponential_and_capped() {
440+
let first = initialize_retry_delay(0);
441+
let second = initialize_retry_delay(1);
442+
let capped = initialize_retry_delay(10);
443+
444+
assert_eq!(first, Duration::from_millis(INITIALIZE_RETRY_BASE_DELAY_MS));
445+
assert_eq!(second, Duration::from_millis(INITIALIZE_RETRY_BASE_DELAY_MS * 2));
446+
assert_eq!(
447+
capped,
448+
Duration::from_millis(INITIALIZE_RETRY_MAX_DELAY_MS)
449+
);
398450
}
399451
}

0 commit comments

Comments
 (0)