Classify retryable exec-server preparation errors by type (#47638)

## Why

Transient connection and registry failures can reach remote filesystem callers as generic `io::ErrorKind::Other` errors. Callers need a typed signal to identify failures that read-only preparation can retry.

## What changed

- Add `ExecServerError::is_retryable_preparation_error()` to classify transient registry, connection, handshake, timeout, and session-attachment failures, including errors wrapped in `ConnectionAttempt`.
- Map these failures to `io::ErrorKind::BrokenPipe` in the remote filesystem adapter.
- Keep authentication, TLS, and malformed protocol failures outside the retryable classification; error text alone does not trigger it.

## Testing

Add a regression test showing that a wrapped registry service-unavailable error maps to `BrokenPipe`, while a protocol error containing transport-closed text remains `Other`.

GitOrigin-RevId: c3e1bbab8f977a0b421eba5d6d3071ada72c09ff
This commit is contained in:
mtsui-oai
2026-09-23 19:37:16 +00:00
committed by copyberry
parent ce7df36237
commit c847294c60
3 changed files with 93 additions and 1 deletions
+67
View File
@@ -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,
}
}
}