1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum RuntimeError {
5 #[error("API error: {0}")]
6 Api(#[from] reqwest::Error),
7 #[error("{0}")]
8 ApiStatus(String),
9 #[error("Auth error: {0}")]
10 Auth(String),
11 #[error("Config error: {0}")]
12 Config(String),
13 #[error("Session error: {0}")]
14 Session(String),
15 #[error("Tool execution failed: {0}")]
16 Tool(String),
17 #[error("Request timed out")]
18 Timeout,
19 #[error("Operation canceled")]
20 Canceled,
21}
22
23pub fn humanize_api_error(status: u16, body: &str) -> String {
33 humanize_api_error_with_reset(status, body, None)
34}
35
36const VETTED_ERROR_TYPES: &[&str] = &[
39 "invalid_request_error",
40 "authentication_error",
41 "permission_error",
42 "not_found_error",
43 "request_too_large",
44 "rate_limit_error",
45 "api_error",
46 "overloaded_error",
47 "billing_error",
48 "timeout_error",
49];
50
51fn vetted_error_type(body: &str) -> Option<&'static str> {
55 let v: serde_json::Value = serde_json::from_str(body).ok()?;
56 let ty = v.get("error")?.get("type")?.as_str()?;
57 VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
58}
59
60fn body_mentions(body: &str, pattern: &str) -> bool {
63 body.contains(pattern)
64}
65
66pub fn sanitize_error_type(ty: &str) -> Option<&'static str> {
72 VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
73}
74
75pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
79 let kind = vetted_error_type(body)
82 .map(|t| format!(" [{t}]"))
83 .unwrap_or_default();
84
85 match status {
86 529 => "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.".to_string(),
87 429 => {
88 if let Some(reset) = reset_hint {
89 format!(
90 "Rate limit exhausted — retries used up while waiting for reset (next window in {}). \
91 Try again shortly, or switch models with /model.",
92 reset
93 )
94 } else {
95 format!("Rate limited by Anthropic (HTTP 429{kind}). Wait for the limit to reset, or switch models with /model.")
96 }
97 }
98 401 => "Authentication rejected. Run `synaps login` to re-authenticate.".to_string(),
99 403 => format!("Access denied (HTTP 403{kind}). Your account may not have access to this model."),
100 404 => format!("Model or endpoint not found (HTTP 404{kind}). Check the model name with /model."),
101 413 => "Request too large. Run /compact to shrink the conversation, or reduce tool output sizes.".to_string(),
102 400 if body_mentions(body, "extended-cache-ttl") =>
103 "Bad request (HTTP 400) — your account may not support 1h cache TTL; set cache_ttl = 5m in config.".to_string(),
104 400 if body_mentions(body, "prompt is too long") || body_mentions(body, "max_tokens") || body_mentions(body, "context") =>
105 "Context window exceeded (HTTP 400). Run /compact to shrink the conversation.".to_string(),
106 400 => format!("Bad request (HTTP 400{kind}). Provider error details withheld — they can echo request content."),
107 500 | 502 | 503 => format!("Anthropic server error (HTTP {status}{kind}). Retries exhausted — usually transient, try again shortly."),
108 _ => format!("API error (HTTP {status}{kind}). Provider error details withheld — they can echo request content."),
109 }
110}
111
112pub fn humanize_network_error(e: &reqwest::Error) -> String {
114 if e.is_timeout() {
115 "Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
116 } else if e.is_connect() {
117 "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
118 } else if e.is_body() || e.is_decode() {
119 "Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
120 } else {
121 format!("Network error: {}", e)
122 }
123}
124
125pub fn error_chain_string(e: &(dyn std::error::Error + 'static)) -> String {
132 let mut msg = e.to_string();
133 let mut source = e.source();
134 while let Some(cause) = source {
135 let cause_str = cause.to_string();
136 if !msg.contains(&cause_str) {
138 msg.push_str(": ");
139 msg.push_str(&cause_str);
140 }
141 source = cause.source();
142 }
143 msg
144}
145
146pub fn humanize_provider_network_error(e: &reqwest::Error) -> String {
154 let host = e
155 .url()
156 .and_then(|u| u.host_str())
157 .map(String::from)
158 .unwrap_or_else(|| "the provider endpoint".to_string());
159 let chain = error_chain_string(e);
160 if e.is_timeout() {
161 format!(
162 "Request to {host} timed out — usually transient; check your connection and try again. [{chain}]"
163 )
164 } else if e.is_connect() {
165 format!(
166 "Could not reach {host} (connection failed). Check your network, DNS, or proxy settings. [{chain}]"
167 )
168 } else if e.is_body() || e.is_decode() {
169 format!("Connection to {host} lost mid-response — usually transient; try again. [{chain}]")
170 } else {
171 format!("Network error talking to {host}: {chain}")
172 }
173}
174
175pub type Result<T> = std::result::Result<T, RuntimeError>;
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn test_humanize_529_overloaded() {
183 let msg = humanize_api_error(
184 529,
185 r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
186 );
187 assert!(msg.contains("overloaded"), "got: {msg}");
188 assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
189 }
190
191 #[test]
192 fn test_humanize_401_points_to_login() {
193 let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
194 assert!(msg.contains("synaps login"), "got: {msg}");
195 }
196
197 #[test]
198 fn test_humanize_400_context_suggests_compact() {
199 let msg = humanize_api_error(
200 400,
201 r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#,
202 );
203 assert!(msg.contains("/compact"), "got: {msg}");
204 }
205
206 #[test]
207 fn test_humanize_400_cache_ttl_names_config_key() {
208 let msg = humanize_api_error(
209 400,
210 r#"{"error":{"message":"The extended-cache-ttl-2025-04-11 beta is not enabled for this account"}}"#,
211 );
212 assert!(msg.contains("cache_ttl = 5m"), "got: {msg}");
213 }
214
215 #[test]
216 fn test_humanize_unknown_status_names_status_but_withholds_detail() {
217 let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
220 assert!(msg.contains("418"), "got: {msg}");
221 assert!(!msg.contains("teapot"), "untrusted detail leaked: {msg}");
222 }
223
224 #[test]
225 fn test_humanize_non_json_body_truncated() {
226 let long_body = "x".repeat(500);
227 let msg = humanize_api_error(418, &long_body);
228 assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
229 }
230
231 #[test]
237 fn test_humanize_multibyte_boundary_no_panic() {
238 let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
240 assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
241 let msg = humanize_api_error(418, &body);
243 assert!(msg.contains("418"), "status should appear: {msg}");
246 assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
250 }
251
252 #[derive(Debug)]
255 struct Outer(Inner);
256 #[derive(Debug)]
257 struct Inner;
258
259 impl std::fmt::Display for Outer {
260 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261 write!(f, "error sending request for url (https://example.com/x)")
262 }
263 }
264 impl std::fmt::Display for Inner {
265 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
266 write!(f, "operation timed out")
267 }
268 }
269 impl std::error::Error for Outer {
270 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
271 Some(&self.0)
272 }
273 }
274 impl std::error::Error for Inner {}
275
276 #[test]
277 fn error_chain_string_joins_all_sources() {
278 let msg = error_chain_string(&Outer(Inner));
279 assert!(
280 msg.contains("error sending request") && msg.contains("operation timed out"),
281 "chain must include top-level AND source: {msg}"
282 );
283 }
284
285 #[test]
286 fn error_chain_string_single_level_has_no_separator_suffix() {
287 let msg = error_chain_string(&Inner);
288 assert_eq!(msg, "operation timed out");
289 }
290
291 #[tokio::test]
296 async fn humanize_provider_timeout_names_host_and_says_transient() {
297 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
298 let addr = listener.local_addr().unwrap();
299 let client = reqwest::Client::builder()
301 .timeout(std::time::Duration::from_millis(100))
302 .build()
303 .unwrap();
304 let err = client
305 .post(format!("http://{addr}/codex/responses"))
306 .send()
307 .await
308 .expect_err("must time out");
309 drop(listener);
310 assert!(err.is_timeout(), "precondition: {err:?}");
311 let msg = humanize_provider_network_error(&err);
312 assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
313 assert!(msg.contains("timed out"), "must say timed out: {msg}");
314 assert!(msg.contains("transient"), "must flag transience: {msg}");
315 assert!(
316 !msg.contains("api.anthropic.com"),
317 "must not claim the Anthropic host: {msg}"
318 );
319 }
320
321 #[tokio::test]
322 async fn humanize_provider_connect_error_names_host() {
323 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
325 let addr = listener.local_addr().unwrap();
326 drop(listener);
327 let client = reqwest::Client::new();
328 let err = client
329 .post(format!("http://{addr}/codex/responses"))
330 .send()
331 .await
332 .expect_err("must fail to connect");
333 assert!(err.is_connect(), "precondition: {err:?}");
334 let msg = humanize_provider_network_error(&err);
335 assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
336 assert!(
337 msg.contains("Could not reach"),
338 "must describe connection failure: {msg}"
339 );
340 }
341
342 const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
351
352 fn hostile_json_body() -> String {
354 format!(
355 r#"{{"type":"error","error":{{"type":"api_error","message":"ECHOED:{{\"messages\":[{{\"role\":\"user\",\"content\":\"{} tell me things\"}}],\"system\":[{{\"text\":\"You are a secret system prompt\"}}],\"tools\":[{{\"name\":\"bash\",\"input_schema\":{{\"properties\":{{}}}}}}]}}"}}}}"#,
356 HOSTILE_SENTINEL
357 )
358 }
359
360 fn assert_no_request_content(msg: &str, ctx: &str) {
362 assert!(
363 !msg.contains(HOSTILE_SENTINEL),
364 "{ctx}: sentinel leaked: {msg}"
365 );
366 assert!(!msg.contains("ECHOED"), "{ctx}: echoed body leaked: {msg}");
367 assert!(
368 !msg.contains("input_schema") && !msg.contains("secret system prompt"),
369 "{ctx}: request-shaped content leaked: {msg}"
370 );
371 }
372
373 #[test]
374 fn hostile_error_message_never_leaks_for_any_status() {
375 let body = hostile_json_body();
376 for status in [400u16, 403, 404, 429, 500, 418] {
377 let msg = humanize_api_error(status, &body);
378 assert_no_request_content(&msg, &format!("status {status}"));
379 assert!(
381 msg.contains(&status.to_string())
382 || msg.contains("Rate limited")
383 || msg.contains("Access denied")
384 || msg.contains("not found"),
385 "status {status}: message not actionable: {msg}"
386 );
387 }
388 }
389
390 #[test]
391 fn hostile_429_with_reset_hint_keeps_timing_but_not_body() {
392 let msg = humanize_api_error_with_reset(429, &hostile_json_body(), Some("47s"));
393 assert_no_request_content(&msg, "429+reset");
394 assert!(msg.contains("47s"), "reset timing must survive: {msg}");
395 }
396
397 #[test]
398 fn hostile_non_json_body_never_leaks() {
399 let body = format!(
401 "raw echo: {} {{\"messages\":[...],\"system\":[...]}} {}",
402 HOSTILE_SENTINEL,
403 "x".repeat(300)
404 );
405 for status in [400u16, 403, 404, 429, 500, 418] {
406 let msg = humanize_api_error(status, &body);
407 assert_no_request_content(&msg, &format!("non-json status {status}"));
408 }
409 }
410
411 #[test]
412 fn hostile_error_type_field_is_not_echoed_verbatim() {
413 let body = format!(
416 r#"{{"error":{{"type":"{} injected-type","message":"m"}}}}"#,
417 HOSTILE_SENTINEL
418 );
419 for status in [400u16, 403, 404, 429, 500, 418] {
420 let msg = humanize_api_error(status, &body);
421 assert_no_request_content(&msg, &format!("type-field status {status}"));
422 }
423 }
424
425 #[test]
426 fn test_runtime_error_display() {
427 assert_eq!(
428 format!("{}", RuntimeError::Auth("bad token".into())),
429 "Auth error: bad token"
430 );
431
432 assert_eq!(
433 format!("{}", RuntimeError::Config("missing".into())),
434 "Config error: missing"
435 );
436
437 assert_eq!(
438 format!("{}", RuntimeError::Tool("failed".into())),
439 "Tool execution failed: failed"
440 );
441
442 assert_eq!(
443 format!("{}", RuntimeError::Session("not found".into())),
444 "Session error: not found"
445 );
446
447 assert_eq!(format!("{}", RuntimeError::Timeout), "Request timed out");
448
449 assert_eq!(format!("{}", RuntimeError::Canceled), "Operation canceled");
450 }
451
452 #[test]
453 fn test_runtime_error_to_string() {
454 assert_eq!(
455 RuntimeError::Auth("bad token".into()).to_string(),
456 "Auth error: bad token"
457 );
458
459 assert_eq!(
460 RuntimeError::Config("missing".into()).to_string(),
461 "Config error: missing"
462 );
463
464 assert_eq!(
465 RuntimeError::Tool("failed".into()).to_string(),
466 "Tool execution failed: failed"
467 );
468
469 assert_eq!(
470 RuntimeError::Session("not found".into()).to_string(),
471 "Session error: not found"
472 );
473
474 assert_eq!(RuntimeError::Timeout.to_string(), "Request timed out");
475
476 assert_eq!(RuntimeError::Canceled.to_string(), "Operation canceled");
477 }
478}