Skip to main content

agent_engine/runtime/openai/
net.rs

1//! Error classification for the OpenAI-compat / Codex provider routes.
2//!
3//! Historically every error escaping `try_route` was blanket-wrapped in
4//! `RuntimeError::Config("openai provider: …")` — so a transient network
5//! blip against `chatgpt.com/backend-api/codex/responses` surfaced to the
6//! user as **"Config error"**, implying their configuration was broken
7//! (real incident: session 20260714-025948-3dab). This module classifies
8//! provider-route errors into honest categories:
9//!
10//! * transport failures (`reqwest::Error`) → `RuntimeError::ApiStatus` with
11//!   a host-aware humanized message that preserves the full cause chain
12//! * upstream HTTP status failures (`codex request failed: 503: …`) →
13//!   `RuntimeError::ApiStatus`
14//! * cancellation → `RuntimeError::Canceled`
15//! * everything else (missing keys, malformed tokens, …) → genuine
16//!   `RuntimeError::Config`, same prefix as before
17
18use crate::core::error::{error_chain_string, humanize_provider_network_error};
19use crate::RuntimeError;
20
21/// Boxed error type produced by the provider routes.
22pub type BoxedProviderError = Box<dyn std::error::Error + Send + Sync>;
23
24/// Classify an error escaping the OpenAI/Codex provider route into the
25/// right `RuntimeError` variant. Also logs the full cause chain at WARN —
26/// provider-route failures were previously invisible in the logs.
27pub fn provider_error_to_runtime(e: BoxedProviderError) -> RuntimeError {
28    // Transport-level failure (connect / timeout / mid-stream drop)?
29    if let Some(re) = find_reqwest_error(e.as_ref()) {
30        let msg = humanize_provider_network_error(re);
31        tracing::warn!(error = %error_chain_string(e.as_ref()), "provider transport error");
32        return RuntimeError::ApiStatus(msg);
33    }
34
35    let msg = error_chain_string(e.as_ref());
36
37    if msg.contains("operation canceled") || msg.contains("request canceled") {
38        return RuntimeError::Canceled;
39    }
40
41    // Upstream returned an HTTP error status or the Responses API reported a
42    // terminal stream failure — API failures, not configuration errors.
43    if msg.starts_with("codex request failed:")
44        || msg.starts_with("openai request failed:")
45        || msg.starts_with("Codex response failed in stream.")
46        || msg.starts_with("Codex response was incomplete.")
47        || msg.starts_with("Codex completed without text or tool output.")
48        || msg.starts_with("Codex response stream ended without a terminal event.")
49    {
50        tracing::warn!(error = %msg, "provider API error");
51        return RuntimeError::ApiStatus(msg);
52    }
53
54    tracing::warn!(error = %msg, "provider config error");
55    RuntimeError::Config(format!("openai provider: {msg}"))
56}
57
58/// Walk the boxed error and its source chain looking for a `reqwest::Error`.
59///
60/// `send().await?` boxes the `reqwest::Error` directly, but helpers may wrap
61/// it another level deep — check the whole chain, not just the top.
62fn find_reqwest_error<'a>(e: &'a (dyn std::error::Error + 'static)) -> Option<&'a reqwest::Error> {
63    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e);
64    while let Some(err) = current {
65        if let Some(re) = err.downcast_ref::<reqwest::Error>() {
66            return Some(re);
67        }
68        current = err.source();
69    }
70    None
71}
72
73/// Strip the provider response-body snippet from a broker proxy error.
74///
75/// `LocalBroker::proxy_stream` flattens an upstream HTTP failure into
76/// `… provider request failed: {status}: {body snippet}`. The snippet is
77/// provider-controlled and may echo the full request (spec §5.1) — a byte
78/// bound is not redaction. Keep everything up to and including the status
79/// (canonical reason phrase, no `:`), drop the snippet. Messages without the
80/// marker pass through unchanged: every other `BrokerError` variant carries
81/// broker-authored, secret-free text, and transport-level reqwest errors are
82/// not response bodies.
83pub(crate) fn redact_provider_proxy_error(msg: &str) -> String {
84    const MARKER: &str = "provider request failed: ";
85    let Some(start) = msg.find(MARKER) else {
86        return msg.to_string();
87    };
88    let status_start = start + MARKER.len();
89    match msg[status_start..].find(':') {
90        Some(idx) => msg[..status_start + idx].to_string(),
91        None => msg.to_string(),
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    async fn connect_error() -> reqwest::Error {
100        // Bind then drop → guaranteed-refused port.
101        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
102        let addr = listener.local_addr().unwrap();
103        drop(listener);
104        reqwest::Client::new()
105            .post(format!("http://{addr}/codex/responses"))
106            .send()
107            .await
108            .expect_err("must fail to connect")
109    }
110
111    #[tokio::test]
112    async fn transport_error_is_api_status_not_config() {
113        let err = connect_error().await;
114        let runtime_err = provider_error_to_runtime(Box::new(err));
115        assert!(
116            matches!(runtime_err, RuntimeError::ApiStatus(_)),
117            "transport failure must classify as ApiStatus, got: {runtime_err:?}"
118        );
119        let display = runtime_err.to_string();
120        assert!(
121            !display.starts_with("Config error"),
122            "transient network failure must not be presented as a config problem: {display}"
123        );
124        assert!(
125            display.contains("127.0.0.1"),
126            "must name the host: {display}"
127        );
128    }
129
130    #[tokio::test]
131    async fn transport_error_preserves_cause_chain() {
132        let err = connect_error().await;
133        let display = provider_error_to_runtime(Box::new(err)).to_string();
134        // The top-level reqwest Display alone says only "error sending
135        // request" — the classified message must keep the underlying cause.
136        assert!(
137            display.contains("refused") || display.contains("connect"),
138            "underlying cause must survive classification: {display}"
139        );
140    }
141
142    #[test]
143    fn upstream_status_error_is_api_status() {
144        let e: BoxedProviderError = "codex request failed: 503: upstream sad".into();
145        let runtime_err = provider_error_to_runtime(e);
146        assert!(
147            matches!(runtime_err, RuntimeError::ApiStatus(_)),
148            "HTTP status failure must classify as ApiStatus, got: {runtime_err:?}"
149        );
150        assert!(runtime_err.to_string().contains("503"));
151    }
152
153    #[test]
154    fn openai_status_error_is_api_status() {
155        let e: BoxedProviderError = "openai request failed: 429: slow down".into();
156        assert!(matches!(
157            provider_error_to_runtime(e),
158            RuntimeError::ApiStatus(_)
159        ));
160    }
161
162    #[test]
163    fn responses_terminal_errors_are_api_status_not_config() {
164        for msg in [
165            "Codex response failed in stream. Provider error details withheld because they can echo request content.",
166            "Codex response was incomplete. Retry the request or reduce the requested output/context size.",
167            "Codex completed without text or tool output. Retry the request.",
168            "Codex response stream ended without a terminal event. Retry the request.",
169        ] {
170            let err = provider_error_to_runtime(msg.into());
171            assert!(
172                matches!(err, RuntimeError::ApiStatus(_)),
173                "Responses terminal must classify as ApiStatus: {err:?}"
174            );
175            assert!(!err.to_string().starts_with("Config error"));
176        }
177    }
178
179    #[test]
180    fn cancellation_is_canceled() {
181        let e: BoxedProviderError = "operation canceled".into();
182        assert!(matches!(
183            provider_error_to_runtime(e),
184            RuntimeError::Canceled
185        ));
186        let e: BoxedProviderError = "request canceled".into();
187        assert!(matches!(
188            provider_error_to_runtime(e),
189            RuntimeError::Canceled
190        ));
191    }
192
193    #[test]
194    fn redact_provider_proxy_error_drops_body_snippet_keeps_status() {
195        let msg = "gemini stream error: broker transport error: provider request failed: \
196                   429 Too Many Requests: {\"error\":{\"message\":\"ECHOED:secret\"}}";
197        let redacted = redact_provider_proxy_error(msg);
198        assert_eq!(
199            redacted,
200            "gemini stream error: broker transport error: provider request failed: \
201             429 Too Many Requests"
202        );
203    }
204
205    #[test]
206    fn redact_provider_proxy_error_passes_through_non_proxy_messages() {
207        for msg in [
208            "broker transport error: connection reset",
209            "no credential configured for 'groq'. Run `synaps login` to add one.",
210            "request canceled",
211        ] {
212            assert_eq!(redact_provider_proxy_error(msg), msg);
213        }
214    }
215
216    #[test]
217    fn genuine_config_problems_stay_config_with_original_prefix() {
218        let e: BoxedProviderError =
219            "No API key for 'groq'. Set provider.groq in ~/.synaps-cli/config or the corresponding env var."
220                .into();
221        let runtime_err = provider_error_to_runtime(e);
222        assert!(
223            matches!(runtime_err, RuntimeError::Config(_)),
224            "missing key IS a config problem, got: {runtime_err:?}"
225        );
226        assert!(
227            runtime_err
228                .to_string()
229                .starts_with("Config error: openai provider: "),
230            "must keep the historical prefix for genuine config errors: {runtime_err}"
231        );
232    }
233}