//! Shared transport-level error classification for provider runtimes. //! //! Every provider (Anthropic, OpenAI, Gemini, Cursor, ...) needs to decide //! whether a request failure is a transient transport fault worth retrying on //! a fresh connection, or a real error to surface. Keeping the classifier here //! ensures all providers recognize the same fault vocabulary. /// Whether an error message describes a transient transport-level fault /// (connection reset, DNS hiccup, TLS teardown, HTTP/2 stream error, ...) /// that is likely to succeed on retry with a fresh connection. pub fn is_transient_transport_error(error_str: &str) -> bool { let lower = error_str.to_ascii_lowercase(); lower.contains("connection reset") || lower.contains("connection closed") || lower.contains("connection refused") || lower.contains("connection aborted") || lower.contains("broken pipe") || lower.contains("timed out") || lower.contains("timeout") || lower.contains("operation timed out") || lower.contains("error decoding") || lower.contains("error reading") || lower.contains("unexpected eof") // rustls reports an abrupt TCP close mid-stream as "peer closed // connection without sending TLS close_notify" (its docs URL spells // it "unexpected-eof", which the space-separated marker above does // not match). || lower.contains("close_notify") || lower.contains("peer closed connection") || lower.contains("tls handshake eof") // reqwest/hyper wrap connect-phase failures as "client error // (Connect)" and connection-level faults as "connection error: ...". || lower.contains("client error (connect)") || lower.contains("connection error") // hyper h1: connection closed while a response was still incomplete. || lower.contains("incomplete message") || lower.contains("request or response body error") || lower.contains("badrecordmac") || lower.contains("bad_record_mac") || lower.contains("fatal alert: badrecordmac") || lower.contains("fatal alert: bad_record_mac") || lower.contains("received fatal alert: badrecordmac") || lower.contains("received fatal alert: bad_record_mac") || lower.contains("decryption failed or bad record mac") || lower.contains("temporary failure in name resolution") || lower.contains("failed to lookup address information") || lower.contains("dns error") || lower.contains("name or service not known") || lower.contains("no route to host") || lower.contains("network is unreachable") || lower.contains("host is unreachable") // HTTP/2 transport faults on reused/multiplexed connections. These are // transient: a fresh connection on retry typically succeeds. Seen as // "http2 error: stream error received: unspecific protocol error detected" // or RST_STREAM / GOAWAY frames from the server or an intermediary. || lower.contains("http2 error") || lower.contains("stream error") || lower.contains("protocol error") || lower.contains("refused_stream") || lower.contains("refused stream") || lower.contains("enhance_your_calm") || lower.contains("goaway") || lower.contains("go away") || lower.contains("sendrequest") } #[cfg(test)] mod tests { use super::is_transient_transport_error; #[test] fn http2_stream_protocol_error_is_transient() { // Exact shape reqwest/h2 surfaces for a reset on a reused HTTP/2 connection. let msg = "error sending request for url (https://api.anthropic.com/v1/messages): \ client error (SendRequest): http2 error: stream error received: \ unspecific protocol error detected"; assert!(is_transient_transport_error(msg)); } #[test] fn http2_goaway_and_refused_stream_are_transient() { assert!(is_transient_transport_error("http2 error: GOAWAY received")); assert!(is_transient_transport_error("stream error: REFUSED_STREAM")); } #[test] fn auth_errors_are_not_transient() { assert!(!is_transient_transport_error("401 unauthorized")); assert!(!is_transient_transport_error("invalid x-api-key")); } /// Real transport-error shapes harvested from ~/.jcode/logs. #[test] fn real_world_transport_errors_are_transient() { let real_errors = [ "client error (Connect): dns error: failed to lookup address information: \ Name or service not known", "client error (SendRequest): http2 error: keep-alive timed out: operation timed out", "client error (SendRequest): connection error: peer closed connection without \ sending TLS close_notify: https://docs.rs/rustls/latest/rustls/manual/_03_howto/index.html#unexpected-eof", "client error (Connect): operation timed out", "client error (SendRequest): connection error: timed out", "client error (Connect): tls handshake eof", "error decoding response body: request or response body error: operation timed out", ]; for error in real_errors { assert!( is_transient_transport_error(error), "should be transient: {error}" ); } } }