agent_engine/runtime/openai/
net.rs1use crate::core::error::{error_chain_string, humanize_provider_network_error};
19use crate::RuntimeError;
20
21pub type BoxedProviderError = Box<dyn std::error::Error + Send + Sync>;
23
24pub fn provider_error_to_runtime(e: BoxedProviderError) -> RuntimeError {
28 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 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
58fn 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
73pub(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 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 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}