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.
27///
28/// Provider-agnostic form: broker status labels are classified but not
29/// humanized. Prefer [`provider_error_to_runtime_for`] when the qualified
30/// model (and therefore the provider key) is known.
31pub fn provider_error_to_runtime(e: BoxedProviderError) -> RuntimeError {
32    provider_error_to_runtime_for("", e)
33}
34
35/// Like [`provider_error_to_runtime`], but `qualified_model` (e.g.
36/// `kimi-code/k3`) lets a vetted broker status label such as
37/// `provider request failed: 403 Forbidden [access_terminated_error]` be
38/// rendered as an actionable, provider-aware message. The label is one of
39/// OUR static constants (selected broker-side, never provider bytes) and the
40/// humanizer only ever returns our own text; the raw prefix message is kept
41/// for the log line.
42pub fn provider_error_to_runtime_for(qualified_model: &str, e: BoxedProviderError) -> RuntimeError {
43    // Transport-level failure (connect / timeout / mid-stream drop)?
44    if let Some(re) = find_reqwest_error(e.as_ref()) {
45        let msg = humanize_provider_network_error(re);
46        tracing::warn!(error = %error_chain_string(e.as_ref()), "provider transport error");
47        return RuntimeError::ApiStatus(msg);
48    }
49
50    let msg = error_chain_string(e.as_ref());
51
52    if msg.contains("operation canceled") || msg.contains("request canceled") {
53        return RuntimeError::Canceled;
54    }
55
56    // Upstream returned an HTTP error status or the Responses API reported a
57    // terminal stream failure — API failures, not configuration errors.
58    if msg.starts_with("codex request failed:")
59        || msg.starts_with("openai request failed:")
60        || is_responses_terminal_failure_message(&msg)
61    {
62        tracing::warn!(error = %msg, "provider API error");
63        if let Some(human) = humanize_broker_status_message(qualified_model, &msg) {
64            return RuntimeError::ApiStatus(human);
65        }
66        return RuntimeError::ApiStatus(msg);
67    }
68
69    tracing::warn!(error = %msg, "provider config error");
70    RuntimeError::Config(format!("openai provider: {msg}"))
71}
72
73/// Render a broker status failure carrying a vetted label as an actionable
74/// message. `None` when the message has no label, the label is unknown to
75/// the humanizer, or the provider key cannot be derived from the model.
76fn humanize_broker_status_message(qualified_model: &str, msg: &str) -> Option<String> {
77    let provider = qualified_model.split_once('/')?.0;
78    if provider.is_empty() {
79        return None;
80    }
81    let status = crate::runtime::trace::openai::broker_error_status(msg)?;
82    let label = broker_error_label(msg)?;
83    crate::core::error::humanize_proxy_status_error(provider, status, Some(label))
84}
85
86/// Extract the `[label]` the broker appends after the canonical reason
87/// phrase (`provider request failed: 403 Forbidden [access_terminated_error]`).
88/// Only identifier-shaped labels are accepted; anything else is `None`.
89pub(crate) fn broker_error_label(msg: &str) -> Option<&str> {
90    const MARKER: &str = "provider request failed: ";
91    let rest = &msg[msg.find(MARKER)? + MARKER.len()..];
92    let open = rest.find(" [")?;
93    let after = &rest[open + 2..];
94    let close = after.find(']')?;
95    let label = &after[..close];
96    (!label.is_empty()
97        && label
98            .bytes()
99            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_'))
100    .then_some(label)
101}
102
103/// Responses-API terminal stream failures are `"{label}{suffix}"` where the
104/// label is a provider display name ("Codex", "xAI") and the suffix is one of
105/// the static templates in `stream.rs`. Match label-independently on the
106/// suffix so every provider label classifies as an API failure.
107fn is_responses_terminal_failure_message(msg: &str) -> bool {
108    use super::stream::{
109        RESPONSES_CAPACITY_SUFFIX, RESPONSES_CONTEXT_SUFFIX, RESPONSES_EMPTY_SUFFIX,
110        RESPONSES_FAILED_SUFFIX, RESPONSES_INCOMPLETE_SUFFIX, RESPONSES_MISSING_TERMINAL_SUFFIX,
111    };
112    const SUFFIXES: &[&str] = &[
113        RESPONSES_FAILED_SUFFIX,
114        RESPONSES_CAPACITY_SUFFIX,
115        RESPONSES_CONTEXT_SUFFIX,
116        RESPONSES_INCOMPLETE_SUFFIX,
117        RESPONSES_EMPTY_SUFFIX,
118        RESPONSES_MISSING_TERMINAL_SUFFIX,
119    ];
120    SUFFIXES.iter().any(|suffix| {
121        msg.strip_suffix(suffix)
122            .is_some_and(|label| !label.is_empty() && !label.contains(char::is_whitespace))
123    })
124}
125
126/// Walk the boxed error and its source chain looking for a `reqwest::Error`.
127///
128/// `send().await?` boxes the `reqwest::Error` directly, but helpers may wrap
129/// it another level deep — check the whole chain, not just the top.
130fn find_reqwest_error<'a>(e: &'a (dyn std::error::Error + 'static)) -> Option<&'a reqwest::Error> {
131    let mut current: Option<&(dyn std::error::Error + 'static)> = Some(e);
132    while let Some(err) = current {
133        if let Some(re) = err.downcast_ref::<reqwest::Error>() {
134            return Some(re);
135        }
136        current = err.source();
137    }
138    None
139}
140
141/// Strip the provider response-body snippet from a broker proxy error.
142///
143/// `LocalBroker::proxy_stream` flattens an upstream HTTP failure into
144/// `… provider request failed: {status}: {body snippet}`. The snippet is
145/// provider-controlled and may echo the full request (spec §5.1) — a byte
146/// bound is not redaction. Keep everything up to and including the status
147/// (canonical reason phrase, no `:`), drop the snippet. Messages without the
148/// marker pass through unchanged: every other `BrokerError` variant carries
149/// broker-authored, secret-free text, and transport-level reqwest errors are
150/// not response bodies.
151pub(crate) fn redact_provider_proxy_error(msg: &str) -> String {
152    const MARKER: &str = "provider request failed: ";
153    let Some(start) = msg.find(MARKER) else {
154        return msg.to_string();
155    };
156    let status_start = start + MARKER.len();
157    match msg[status_start..].find(':') {
158        Some(idx) => msg[..status_start + idx].to_string(),
159        None => msg.to_string(),
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    async fn connect_error() -> reqwest::Error {
168        // Bind then drop → guaranteed-refused port.
169        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
170        let addr = listener.local_addr().unwrap();
171        drop(listener);
172        reqwest::Client::new()
173            .post(format!("http://{addr}/codex/responses"))
174            .send()
175            .await
176            .expect_err("must fail to connect")
177    }
178
179    #[tokio::test]
180    async fn transport_error_is_api_status_not_config() {
181        let err = connect_error().await;
182        let runtime_err = provider_error_to_runtime(Box::new(err));
183        assert!(
184            matches!(runtime_err, RuntimeError::ApiStatus(_)),
185            "transport failure must classify as ApiStatus, got: {runtime_err:?}"
186        );
187        let display = runtime_err.to_string();
188        assert!(
189            !display.starts_with("Config error"),
190            "transient network failure must not be presented as a config problem: {display}"
191        );
192        assert!(
193            display.contains("127.0.0.1"),
194            "must name the host: {display}"
195        );
196    }
197
198    #[tokio::test]
199    async fn transport_error_preserves_cause_chain() {
200        let err = connect_error().await;
201        let display = provider_error_to_runtime(Box::new(err)).to_string();
202        // The top-level reqwest Display alone says only "error sending
203        // request" — the classified message must keep the underlying cause.
204        assert!(
205            display.contains("refused") || display.contains("connect"),
206            "underlying cause must survive classification: {display}"
207        );
208    }
209
210    #[test]
211    fn upstream_status_error_is_api_status() {
212        let e: BoxedProviderError = "codex request failed: 503: upstream sad".into();
213        let runtime_err = provider_error_to_runtime(e);
214        assert!(
215            matches!(runtime_err, RuntimeError::ApiStatus(_)),
216            "HTTP status failure must classify as ApiStatus, got: {runtime_err:?}"
217        );
218        assert!(runtime_err.to_string().contains("503"));
219    }
220
221    #[test]
222    fn openai_status_error_is_api_status() {
223        let e: BoxedProviderError = "openai request failed: 429: slow down".into();
224        assert!(matches!(
225            provider_error_to_runtime(e),
226            RuntimeError::ApiStatus(_)
227        ));
228    }
229
230    #[test]
231    fn responses_terminal_errors_are_api_status_not_config() {
232        for msg in [
233            "Codex response failed in stream. Provider error details withheld because they can echo request content.",
234            "Codex response was incomplete. Retry the request or reduce the requested output/context size.",
235            "Codex completed without text or tool output. Retry the request.",
236            "Codex response stream ended without a terminal event. Retry the request.",
237            "Codex rejected the request: the conversation exceeds this model's context window. Run /compact or start a fresh session to continue.",
238            // xAI-labelled variants (xai-auth Responses route).
239            "xAI response failed in stream. Provider error details withheld because they can echo request content.",
240            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model.",
241            "xAI completed without text or tool output. Retry the request.",
242            "xAI response stream ended without a terminal event. Retry the request.",
243            "xAI response was incomplete. Retry the request or reduce the requested output/context size.",
244        ] {
245            let err = provider_error_to_runtime(msg.into());
246            assert!(
247                matches!(err, RuntimeError::ApiStatus(_)),
248                "Responses terminal must classify as ApiStatus: {err:?}"
249            );
250            assert!(!err.to_string().starts_with("Config error"));
251        }
252    }
253
254    /// A message that merely mentions a suffix mid-sentence (or carries a
255    /// non-label prefix) must not be mistaken for a Responses terminal.
256    #[test]
257    fn responses_terminal_matcher_requires_bare_label_prefix() {
258        assert!(!is_responses_terminal_failure_message(
259            "No API key for 'xai'. response failed in stream."
260        ));
261        assert!(!is_responses_terminal_failure_message(
262            " response failed in stream. Provider error details withheld because they can echo request content."
263        ));
264        assert!(is_responses_terminal_failure_message(
265            "xAI reports the model is at capacity (retries exhausted). Try again in a few minutes or switch models with /model."
266        ));
267    }
268
269    #[test]
270    fn cancellation_is_canceled() {
271        let e: BoxedProviderError = "operation canceled".into();
272        assert!(matches!(
273            provider_error_to_runtime(e),
274            RuntimeError::Canceled
275        ));
276        let e: BoxedProviderError = "request canceled".into();
277        assert!(matches!(
278            provider_error_to_runtime(e),
279            RuntimeError::Canceled
280        ));
281    }
282
283    #[test]
284    fn redact_provider_proxy_error_drops_body_snippet_keeps_status() {
285        let msg = "gemini stream error: broker transport error: provider request failed: \
286                   429 Too Many Requests: {\"error\":{\"message\":\"ECHOED:secret\"}}";
287        let redacted = redact_provider_proxy_error(msg);
288        assert_eq!(
289            redacted,
290            "gemini stream error: broker transport error: provider request failed: \
291             429 Too Many Requests"
292        );
293    }
294
295    #[test]
296    fn redact_provider_proxy_error_passes_through_non_proxy_messages() {
297        for msg in [
298            "broker transport error: connection reset",
299            "no credential configured for 'groq'. Run `synaps login` to add one.",
300            "request canceled",
301        ] {
302            assert_eq!(redact_provider_proxy_error(msg), msg);
303        }
304    }
305
306    #[test]
307    fn genuine_config_problems_stay_config_with_original_prefix() {
308        let e: BoxedProviderError =
309            "No API key for 'groq'. Set provider.groq in ~/.synaps-cli/config or the corresponding env var."
310                .into();
311        let runtime_err = provider_error_to_runtime(e);
312        assert!(
313            matches!(runtime_err, RuntimeError::Config(_)),
314            "missing key IS a config problem, got: {runtime_err:?}"
315        );
316        assert!(
317            runtime_err
318                .to_string()
319                .starts_with("Config error: openai provider: "),
320            "must keep the historical prefix for genuine config errors: {runtime_err}"
321        );
322    }
323
324    /// A broker status failure carrying a vetted label is humanized into an
325    /// actionable, provider-aware message when the qualified model is known
326    /// — and never echoes anything but our own text.
327    #[test]
328    fn vetted_kimi_quota_label_is_humanized_for_known_provider() {
329        let raw = "openai request failed: broker transport error: provider request failed: 403 Forbidden [access_terminated_error]";
330        let err: BoxedProviderError = raw.into();
331        let runtime_err = provider_error_to_runtime_for("kimi-code/k3", err);
332        let RuntimeError::ApiStatus(msg) = runtime_err else {
333            panic!("expected ApiStatus, got {runtime_err:?}");
334        };
335        assert!(
336            msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
337            "{msg}"
338        );
339        assert!(msg.contains("weekly"), "{msg}");
340        assert!(
341            !msg.contains("login"),
342            "quota must not read as an auth failure: {msg}"
343        );
344        assert!(
345            !msg.contains("[access_terminated_error]"),
346            "label must be rendered, not leaked: {msg}"
347        );
348    }
349
350    /// Without a provider key (legacy entry point) or without a label the
351    /// raw, status-only prefix message is preserved verbatim.
352    #[test]
353    fn broker_status_without_label_or_provider_keeps_raw_message() {
354        let labelled = "openai request failed: broker transport error: provider request failed: 403 Forbidden [access_terminated_error]";
355        match provider_error_to_runtime(labelled.into()) {
356            RuntimeError::ApiStatus(msg) => assert_eq!(msg, labelled),
357            other => panic!("expected ApiStatus, got {other:?}"),
358        }
359        let plain =
360            "openai request failed: broker transport error: provider request failed: 403 Forbidden";
361        match provider_error_to_runtime_for("kimi-code/k3", plain.into()) {
362            RuntimeError::ApiStatus(msg) => assert_eq!(msg, plain),
363            other => panic!("expected ApiStatus, got {other:?}"),
364        }
365        // Unknown (status, label) combos also fall through unchanged.
366        let unknown =
367            "openai request failed: provider request failed: 418 I'm a teapot [server_error]";
368        match provider_error_to_runtime_for("groq/llama", unknown.into()) {
369            RuntimeError::ApiStatus(msg) => assert_eq!(msg, unknown),
370            other => panic!("expected ApiStatus, got {other:?}"),
371        }
372    }
373
374    #[test]
375    fn broker_error_label_accepts_only_identifier_shaped_labels() {
376        assert_eq!(
377            broker_error_label(
378                "x: provider request failed: 403 Forbidden [access_terminated_error]"
379            ),
380            Some("access_terminated_error")
381        );
382        assert_eq!(
383            broker_error_label("provider request failed: 403 Forbidden"),
384            None
385        );
386        assert_eq!(
387            broker_error_label("provider request failed: 403 Forbidden []"),
388            None
389        );
390        assert_eq!(
391            broker_error_label("provider request failed: 403 Forbidden [<script>ECHO]"),
392            None
393        );
394        assert_eq!(
395            broker_error_label("provider request failed: 403 Forbidden [Mixed-Case]"),
396            None
397        );
398        assert_eq!(
399            broker_error_label("no marker here [access_terminated_error]"),
400            None
401        );
402    }
403}