diff --git a/codex-rs/exec-server/src/client_error.rs b/codex-rs/exec-server/src/client_error.rs new file mode 100644 index 0000000000..e77d56a0f7 --- /dev/null +++ b/codex-rs/exec-server/src/client_error.rs @@ -0,0 +1,67 @@ +//! Classifies typed executor failures without treating error text as a retry signal. + +use std::io::ErrorKind; + +use http::StatusCode; +use tokio_tungstenite::tungstenite::Error as WebSocketError; +use tokio_tungstenite::tungstenite::error::ProtocolError; + +use crate::ExecServerError; +use crate::client::is_retryable_registry_error; +use crate::rpc::SESSION_ALREADY_ATTACHED_ERROR_CODE; + +impl ExecServerError { + /// Identifies transient failures that read-only preparation can retry. + /// + /// Includes transient handshake responses and connection EOFs, including wrapped + /// failures. Authentication, TLS, and malformed protocol errors are excluded. + /// This is public because callers in `codex-core` also classify capability discovery. + pub fn is_retryable_preparation_error(&self) -> bool { + let mut error = self; + while let Self::ConnectionAttempt(source) = error { + error = source; + } + if is_retryable_registry_error(error) { + return true; + } + match error { + Self::Closed + | Self::Disconnected(_) + | Self::WebSocketConnectTimeout { .. } + | Self::InitializeTimedOut { .. } => true, + Self::WebSocketConnect { source, .. } => match source { + WebSocketError::ConnectionClosed + | WebSocketError::AlreadyClosed + | WebSocketError::Protocol( + ProtocolError::HandshakeIncomplete + | ProtocolError::ResetWithoutClosingHandshake, + ) => true, + WebSocketError::Http(response) => { + response.status().is_server_error() + || matches!( + response.status(), + StatusCode::REQUEST_TIMEOUT + | StatusCode::CONFLICT + | StatusCode::TOO_MANY_REQUESTS + ) + } + WebSocketError::Io(error) => matches!( + error.kind(), + ErrorKind::ConnectionRefused + | ErrorKind::ConnectionReset + | ErrorKind::ConnectionAborted + | ErrorKind::NetworkDown + | ErrorKind::NetworkUnreachable + | ErrorKind::HostUnreachable + | ErrorKind::BrokenPipe + | ErrorKind::NotConnected + | ErrorKind::UnexpectedEof + | ErrorKind::TimedOut + ), + _ => false, + }, + Self::Server { code, .. } if *code == SESSION_ALREADY_ATTACHED_ERROR_CODE => true, + _ => false, + } + } +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 2671e83382..e96348ff77 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -3,6 +3,7 @@ mod capability_discovery; mod capability_discovery_cache; mod client; mod client_api; +mod client_error; mod client_telemetry; mod client_transport; mod connection; diff --git a/codex-rs/exec-server/src/remote_file_system.rs b/codex-rs/exec-server/src/remote_file_system.rs index 353dd24f32..509e00d551 100644 --- a/codex-rs/exec-server/src/remote_file_system.rs +++ b/codex-rs/exec-server/src/remote_file_system.rs @@ -415,10 +415,13 @@ fn map_remote_error(error: ExecServerError) -> io::Error { ExecServerError::Server { code, message } if code == INVALID_REQUEST_ERROR_CODE => { io::Error::new(io::ErrorKind::InvalidInput, message) } - ExecServerError::Server { message, .. } => io::Error::other(message), ExecServerError::Closed | ExecServerError::Disconnected(_) => { io::Error::new(io::ErrorKind::BrokenPipe, "exec-server transport closed") } + error if error.is_retryable_preparation_error() => { + io::Error::new(io::ErrorKind::BrokenPipe, error.to_string()) + } + ExecServerError::Server { message, .. } => io::Error::other(message), _ => io::Error::other(error.to_string()), } } @@ -462,4 +465,25 @@ mod tests { ] ); } + + #[test] + fn preparation_retryability_controls_filesystem_error_kind() { + let errors = [ + ExecServerError::ConnectionAttempt(Arc::new( + ExecServerError::EnvironmentRegistryHttp { + status: http::StatusCode::SERVICE_UNAVAILABLE, + code: None, + message: "registry unavailable".to_string(), + }, + )), + ExecServerError::Protocol("exec-server transport closed".to_string()), + ]; + + let kinds = errors + .into_iter() + .map(|error| map_remote_error(error).kind()) + .collect::>(); + + assert_eq!(kinds, vec![io::ErrorKind::BrokenPipe, io::ErrorKind::Other]); + } }