Files
codex/codex-rs/rmcp-client/src/streamable_http_retry_tests.rs
T
ssetty-oai 490340ffcf [codex] Retry streamable HTTP initialize failures (#25147)
## Summary
- Retry transient streamable HTTP failures during RMCP startup when the
failure happens while sending the initialize request.
- Retry transient streamable HTTP failures for tools/list, which is
read-only and safe to replay.
- Cover both retryable HTTP statuses and request-layer failures where no
HTTP status is returned.
- Surface retryable HTTP statuses from the streamable HTTP adapter as
typed client errors.
- Add integration coverage for initialize retry, tools/list retry,
no-status request failure retry, and non-retryable initialize status.

## Root cause
The observed codex_apps failures can happen before normal tool
execution: RMCP startup fails while sending initialize, or the first
read-only tools/list fails after startup. Retrying hosted_apps_bridge
tools/call would not cover initialize and would risk replaying
side-effecting tool calls. This change retries the streamable HTTP
handshake itself, recreates the transport between initialize attempts,
and retries only tools/list among post-initialize service operations.

## Validation
- cargo fmt --package codex-rmcp-client
- cargo test -p codex-rmcp-client --test streamable_http_recovery
2026-06-09 15:49:48 -07:00

75 lines
2.6 KiB
Rust

use std::any::TypeId;
use codex_exec_server::ExecServerError;
use pretty_assertions::assert_eq;
use rmcp::transport::DynamicTransportError;
use rmcp::transport::streamable_http_client::StreamableHttpError;
use crate::http_client_adapter::StreamableHttpClientAdapterError;
use super::*;
#[test]
fn retryable_initialize_error_includes_initialized_notification_context() {
let contexts = [
"send initialize request",
"send initialized notification",
"receive initialize response",
];
assert_eq!(
contexts.map(|context| {
RmcpClient::is_retryable_client_initialize_error(&retryable_initialize_error(context))
}),
[true, true, false],
);
}
#[test]
fn retryable_streamable_http_error_includes_remote_body_stream_failure() {
let errors = [
StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(
ExecServerError::HttpRequest("error sending request for url".to_string()),
)),
StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(
ExecServerError::Server {
code: JSON_RPC_INTERNAL_ERROR_CODE,
message: "http/request failed: error sending request for url".to_string(),
},
)),
StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(
ExecServerError::Protocol(
"http response stream `http-1` failed: exec-server transport disconnected"
.to_string(),
),
)),
StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(
ExecServerError::Protocol(
"http response stream `http-1` received seq 2, expected 1".to_string(),
),
)),
StreamableHttpError::UnexpectedServerResponse("HTTP 502: upstream failure".into()),
StreamableHttpError::UnexpectedServerResponse("HTTP 400: bad request".into()),
];
assert_eq!(
errors.map(|error| RmcpClient::is_retryable_streamable_http_error(&error)),
[true, true, true, false, true, false],
);
}
fn retryable_initialize_error(context: &'static str) -> rmcp::service::ClientInitializeError {
rmcp::service::ClientInitializeError::TransportError {
error: DynamicTransportError::from_parts(
"streamable_http",
TypeId::of::<()>(),
Box::new(StreamableHttpError::Client(
StreamableHttpClientAdapterError::HttpRequest(ExecServerError::HttpRequest(
"error sending request for url".to_string(),
)),
)),
),
context: context.into(),
}
}