Files
codex/codex-rs/rmcp-client/src/streamable_http_retry_tests.rs
T
yitaoh-oai 3c074bb44a Add model Search discovery to MCP bindings (#59034)
## What changed

Add `McpBinding` APIs to discover the model's Search tool profile through an already authorized, host-owned Apps connection. Discovery sends turn metadata, paginates `tools/list`, and returns a scoped binding with the refreshed declaration and connector-provided invocation context.

Require a visible Search tool and an object-valued invocation context. Preserve the context only for calls using the same client, while allowing normal dispatch to recheck visibility, account selection, and approval policy.

Bound discovery to five seconds and distinguish critical timeout, transport, and server errors from unavailability. Expose the existing retryable transport error classifier for this distinction.

## Testing

Add coverage for opaque invocation context extraction and extend transport error classification coverage to HTTP 503 and 403 responses, exercising the exported classifier through a contextual error.

GitOrigin-RevId: 9848d4adb91e3fb2b9406349c31d1fd5c5c47a33
2026-11-03 01:59:24 +00:00

133 lines
5.0 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::AuthRequiredError;
use rmcp::transport::streamable_http_client::StreamableHttpError;
use crate::http_client_adapter::StreamableHttpClientAdapterError;
use crate::rmcp_client::ClientOperationError;
use super::*;
#[test]
fn retryable_initialize_error_includes_discovery_and_initialized_notification_context() {
let contexts = [
"send discover request",
"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, true, false],
);
}
#[test]
fn streamable_http_retry_classifies_transport_and_auth_errors() {
let errors = [
StreamableHttpError::Client(StreamableHttpClientAdapterError::HttpRequest(
ExecServerError::RequestNotSent("enterprise policy is updating".to_string()),
)),
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 503: service unavailable".into()),
StreamableHttpError::UnexpectedServerResponse("HTTP 403: forbidden".into()),
StreamableHttpError::UnexpectedServerResponse("HTTP 400: bad request".into()),
StreamableHttpError::Client(StreamableHttpClientAdapterError::Auth(
codex_api::AuthError::Transient("credential refresh unavailable".to_string()),
)),
StreamableHttpError::Client(StreamableHttpClientAdapterError::Auth(
codex_api::AuthError::Build("invalid authentication".to_string()),
)),
StreamableHttpError::Client(StreamableHttpClientAdapterError::Auth(
codex_api::AuthError::Policy(codex_http_client::NetworkPolicyDenied::Revoked),
)),
];
assert_eq!(
errors.map(|error| {
let transport = DynamicTransportError::from_parts(
"streamable_http",
TypeId::of::<()>(),
Box::new(error),
);
let error =
ClientOperationError::from(rmcp::service::ServiceError::TransportSend(transport));
crate::is_retryable_transport_error(&anyhow::Error::new(error).context("listing tools"))
}),
[
false, true, true, true, false, true, true, false, false, true, false, false
],
);
}
#[test]
fn startup_http_authentication_challenges_require_reauthorization() {
let transport_error = || {
DynamicTransportError::from_parts(
"streamable_http",
TypeId::of::<()>(),
Box::new(
StreamableHttpError::<StreamableHttpClientAdapterError>::AuthRequired(
AuthRequiredError::new("Bearer error=\"invalid_token\"".to_string()),
),
),
)
};
let errors = [
anyhow::Error::new(rmcp::service::ClientInitializeError::TransportError {
error: transport_error(),
context: "send initialize request".into(),
}),
anyhow::Error::new(ClientOperationError::from(
rmcp::service::ServiceError::TransportSend(transport_error()),
)),
];
for error in errors {
assert!(crate::startup_error::is_authentication_required_error(
&error
));
}
}
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(),
}
}