Codex Handbook
rmcp-client/src/streamable_http_retry.rs 239 lines
use std::sync::Arc;use std::time::Duration;use std::time::Instant;use anyhow::Result;use anyhow::anyhow;use codex_exec_server::ExecServerError;use reqwest::StatusCode;use rmcp::service::RoleClient;use rmcp::service::RunningService;use rmcp::transport::streamable_http_client::StreamableHttpError;use tokio::time;use tracing::warn;use crate::elicitation_client_service::ElicitationClientService;use crate::http_client_adapter::StreamableHttpClientAdapterError;use crate::oauth::OAuthPersistor;use super::PendingTransport;use super::RmcpClient;const JSON_RPC_INTERNAL_ERROR_CODE: i64 = -32603;pub(super) const STREAMABLE_HTTP_RETRY_DELAYS_MS: [u64; 2] = [250, 1_000];impl RmcpClient {    pub(super) async fn connect_pending_transport_with_initialize_retries(        &self,        initial_transport: PendingTransport,        client_service: ElicitationClientService,        timeout: Option<Duration>,    ) -> Result<(        Arc<RunningService<RoleClient, ElicitationClientService>>,        Option<OAuthPersistor>,    )> {        let should_retry = match &initial_transport {            PendingTransport::InProcess { .. } | PendingTransport::Stdio { .. } => false,            PendingTransport::StreamableHttp { .. }            | PendingTransport::StreamableHttpWithOAuth { .. } => true,        };        let retry_deadline = timeout.map(|duration| Instant::now() + duration);        let mut pending_transport = Some(initial_transport);        for (attempt, retry_delay_ms) in STREAMABLE_HTTP_RETRY_DELAYS_MS            .iter()            .copied()            .map(Some)            .chain(std::iter::once(None))            .enumerate()        {            let transport = match pending_transport.take() {                Some(transport) => transport,                None => {                    let remaining = remaining_initialize_timeout(timeout, retry_deadline)?;                    match remaining {                        Some(remaining) => time::timeout(                            remaining,                            Self::create_pending_transport(&self.transport_recipe),                        )                        .await                        .map_err(|_| initialize_timeout_error(timeout, remaining))??,                        None => Self::create_pending_transport(&self.transport_recipe).await?,                    }                }            };            let attempt_timeout = remaining_initialize_timeout(timeout, retry_deadline)?;            match Self::connect_pending_transport(                transport,                client_service.clone(),                attempt_timeout,            )            .await            {                Ok(result) => return Ok(result),                Err(error) if should_retry && Self::is_retryable_initialize_error(&error) => {                    let Some(retry_delay_ms) = retry_delay_ms else {                        return Err(error);                    };                    let delay = Duration::from_millis(retry_delay_ms);                    warn!(                        attempt = attempt + 1,                        max_attempts = STREAMABLE_HTTP_RETRY_DELAYS_MS.len() + 1,                        delay_ms = delay.as_millis(),                        error = %error,                        "streamable HTTP MCP initialize failed with a retryable error; retrying"                    );                    if !sleep_with_retry_deadline(delay, retry_deadline).await {                        let duration = timeout.unwrap_or(delay);                        return Err(anyhow!(                            "timed out handshaking with MCP server after {duration:?}"                        ));                    }                }                Err(error) => return Err(error),            }        }        unreachable!("initialize retry loop should return on success or final error")    }    fn is_retryable_initialize_error(error: &anyhow::Error) -> bool {        error.chain().any(|source| {            source                .downcast_ref::<HandshakeError>()                .is_some_and(|error| Self::is_retryable_client_initialize_error(&error.source))                || source                    .downcast_ref::<rmcp::service::ClientInitializeError>()                    .is_some_and(Self::is_retryable_client_initialize_error)        })    }    fn is_retryable_client_initialize_error(error: &rmcp::service::ClientInitializeError) -> bool {        match error {            rmcp::service::ClientInitializeError::TransportError { error, context }                if context.as_ref() == "send initialize request" =>            {                error                    .error                    .downcast_ref::<StreamableHttpError<StreamableHttpClientAdapterError>>()                    .is_some_and(Self::is_retryable_streamable_http_error)            }            rmcp::service::ClientInitializeError::TransportError { error, context }                if context.as_ref() == "send initialized notification" =>            {                error                    .error                    .downcast_ref::<StreamableHttpError<StreamableHttpClientAdapterError>>()                    .is_some_and(|error| {                        matches!(error, StreamableHttpError::TransportChannelClosed)                            || Self::is_retryable_streamable_http_error(error)                    })            }            _ => false,        }    }    pub(super) fn is_retryable_streamable_http_error(        error: &StreamableHttpError<StreamableHttpClientAdapterError>,    ) -> bool {        match error {            StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(                ExecServerError::HttpRequest(_),            )) => true,            StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(                ExecServerError::Server { code, message },            )) => {                *code == JSON_RPC_INTERNAL_ERROR_CODE && message.starts_with("http/request failed:")            }            StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(                ExecServerError::Protocol(message),            )) => message.starts_with("http response stream `") && message.contains("` failed:"),            StreamableHttpError::UnexpectedServerResponse(message) => {                is_retryable_unexpected_server_response(message.as_ref())            }            StreamableHttpError::AuthRequired(_)            | StreamableHttpError::InsufficientScope(_)            | StreamableHttpError::SessionExpired            | StreamableHttpError::UnexpectedContentType(_)            | StreamableHttpError::ServerDoesNotSupportSse            | StreamableHttpError::Deserialize(_)            | StreamableHttpError::Client(StreamableHttpClientAdapterError::SessionExpired404)            | StreamableHttpError::Client(StreamableHttpClientAdapterError::Header(_)) => false,            _ => false,        }    }}fn is_retryable_unexpected_server_response(message: &str) -> bool {    let Some(message) = message.strip_prefix("HTTP ") else {        return false;    };    let status_code = message        .chars()        .take_while(char::is_ascii_digit)        .collect::<String>();    let Ok(status) = status_code.parse::<u16>() else {        return false;    };    let Ok(status) = StatusCode::from_u16(status) else {        return false;    };    is_retryable_http_status(status)}fn is_retryable_http_status(status: StatusCode) -> bool {    matches!(        status,        StatusCode::REQUEST_TIMEOUT            | StatusCode::TOO_MANY_REQUESTS            | StatusCode::INTERNAL_SERVER_ERROR            | StatusCode::BAD_GATEWAY            | StatusCode::SERVICE_UNAVAILABLE            | StatusCode::GATEWAY_TIMEOUT    )}fn remaining_initialize_timeout(    timeout: Option<Duration>,    deadline: Option<Instant>,) -> Result<Option<Duration>> {    let Some(deadline) = deadline else {        return Ok(None);    };    let remaining = deadline.saturating_duration_since(Instant::now());    if remaining.is_zero() {        Err(initialize_timeout_error(timeout, remaining))    } else {        Ok(Some(remaining))    }}fn initialize_timeout_error(timeout: Option<Duration>, fallback: Duration) -> anyhow::Error {    let duration = timeout.unwrap_or(fallback);    anyhow!("timed out handshaking with MCP server after {duration:?}")}pub(super) async fn sleep_with_retry_deadline(delay: Duration, deadline: Option<Instant>) -> bool {    if let Some(deadline) = deadline {        let remaining = deadline.saturating_duration_since(Instant::now());        if remaining.is_zero() {            return false;        }        time::timeout(remaining, time::sleep(delay)).await.is_ok()    } else {        time::sleep(delay).await;        true    }}#[derive(Debug, thiserror::Error)]#[error("handshaking with MCP server failed: {source}")]pub(super) struct HandshakeError {    #[source]    pub(super) source: rmcp::service::ClientInitializeError,}#[cfg(test)]#[path = "streamable_http_retry_tests.rs"]mod tests;