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
60pub const VETTED_PROXY_ERROR_TYPES: &[&str] = &[
70 "invalid_request_error",
72 "authentication_error",
73 "permission_error",
74 "not_found_error",
75 "request_too_large",
76 "rate_limit_error",
77 "api_error",
78 "overloaded_error",
79 "billing_error",
80 "timeout_error",
81 "access_terminated_error",
83 "invalid_authentication_error",
84 "insufficient_quota",
85 "quota_exceeded",
86 "server_error",
87 "model_not_found",
88 "invalid_api_key",
89];
90
91pub fn vetted_proxy_error_label(body: &str) -> Option<&'static str> {
99 let v: serde_json::Value = serde_json::from_str(body).ok()?;
100 let candidates = [
101 v.get("error").and_then(|e| e.get("type")),
102 v.get("error").and_then(|e| e.get("code")),
103 v.get("type"),
104 v.get("code"),
105 ];
106 let label = candidates
107 .into_iter()
108 .flatten()
109 .filter_map(serde_json::Value::as_str)
110 .find_map(|ty| VETTED_PROXY_ERROR_TYPES.iter().find(|t| **t == ty).copied());
111 label
112}
113
114pub fn humanize_proxy_status_error(
122 provider: &str,
123 status: u16,
124 label: Option<&str>,
125) -> Option<String> {
126 let label = label.and_then(|l| VETTED_PROXY_ERROR_TYPES.iter().find(|t| **t == l).copied());
127 match (status, label?) {
128 (403, "access_terminated_error") => {
129 let mut msg = format!(
130 "{provider}: usage quota exhausted (HTTP 403). Your plan's usage window is used up — \
131 wait for the reset or upgrade, or switch models with /model."
132 );
133 if provider == "kimi-code" {
134 msg.push_str(
135 " Kimi Code quotas are weekly; the Kimi membership page shows the reset time.",
136 );
137 }
138 Some(msg)
139 }
140 (401, "invalid_authentication_error" | "authentication_error" | "invalid_api_key") => {
141 Some(format!(
142 "{provider}: authentication rejected (HTTP 401). Run `synaps login --provider {provider}`."
143 ))
144 }
145 (429, "rate_limit_error") => Some(format!(
146 "{provider}: rate limited (HTTP 429). Wait for the limit to reset, or switch models with /model."
147 )),
148 (429, "insufficient_quota" | "quota_exceeded") => Some(format!(
149 "{provider}: usage quota exhausted (HTTP 429). Add credit or wait for the quota to reset, \
150 or switch models with /model."
151 )),
152 _ => None,
153 }
154}
155
156fn body_mentions(body: &str, pattern: &str) -> bool {
159 body.contains(pattern)
160}
161
162pub fn sanitize_error_type(ty: &str) -> Option<&'static str> {
168 VETTED_ERROR_TYPES.iter().find(|t| **t == ty).copied()
169}
170
171pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
175 let kind = vetted_error_type(body)
178 .map(|t| format!(" [{t}]"))
179 .unwrap_or_default();
180
181 match status {
182 529 => "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.".to_string(),
183 429 => {
184 if let Some(reset) = reset_hint {
185 format!(
186 "Rate limit exhausted — retries used up while waiting for reset (next window in {}). \
187 Try again shortly, or switch models with /model.",
188 reset
189 )
190 } else {
191 format!("Rate limited by Anthropic (HTTP 429{kind}). Wait for the limit to reset, or switch models with /model.")
192 }
193 }
194 401 => "Authentication rejected. Run `synaps login` to re-authenticate.".to_string(),
195 403 => format!("Access denied (HTTP 403{kind}). Your account may not have access to this model."),
196 404 => format!("Model or endpoint not found (HTTP 404{kind}). Check the model name with /model."),
197 413 => "Request too large. Run /compact to shrink the conversation, or reduce tool output sizes.".to_string(),
198 400 if body_mentions(body, "Consumer Terms") =>
199 "Anthropic requires accepting updated Consumer Terms: sign in at claude.ai with this account, accept the terms, then retry.".to_string(),
200 400 if body_mentions(body, "many-image") =>
204 "Too many large images (HTTP 400): once a request holds more than 20 images, Anthropic accepts only images up to 2000 px per side. Run /compact to drop old images from the conversation.".to_string(),
205 400 if body_mentions(body, "extended-cache-ttl") =>
206 "Bad request (HTTP 400) — your account may not support 1h cache TTL; set cache_ttl = 5m in config.".to_string(),
207 400 if body_mentions(body, "prompt is too long") || body_mentions(body, "max_tokens") || body_mentions(body, "context") =>
208 "Context window exceeded (HTTP 400). Run /compact to shrink the conversation.".to_string(),
209 400 => format!("Bad request (HTTP 400{kind}). Provider error details withheld — they can echo request content."),
210 500 | 502 | 503 => format!("Anthropic server error (HTTP {status}{kind}). Retries exhausted — usually transient, try again shortly."),
211 _ => format!("API error (HTTP {status}{kind}). Provider error details withheld — they can echo request content."),
212 }
213}
214
215pub fn humanize_network_error(e: &reqwest::Error) -> String {
217 if e.is_timeout() {
218 "Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
219 } else if e.is_connect() {
220 "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
221 } else if e.is_body() || e.is_decode() {
222 "Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
223 } else {
224 format!("Network error: {}", e)
225 }
226}
227
228pub fn error_chain_string(e: &(dyn std::error::Error + 'static)) -> String {
235 let mut msg = e.to_string();
236 let mut source = e.source();
237 while let Some(cause) = source {
238 let cause_str = cause.to_string();
239 if !msg.contains(&cause_str) {
241 msg.push_str(": ");
242 msg.push_str(&cause_str);
243 }
244 source = cause.source();
245 }
246 msg
247}
248
249pub fn humanize_provider_network_error(e: &reqwest::Error) -> String {
257 let host = e
258 .url()
259 .and_then(|u| u.host_str())
260 .map(String::from)
261 .unwrap_or_else(|| "the provider endpoint".to_string());
262 let chain = error_chain_string(e);
263 if e.is_timeout() {
264 format!(
265 "Request to {host} timed out — usually transient; check your connection and try again. [{chain}]"
266 )
267 } else if e.is_connect() {
268 format!(
269 "Could not reach {host} (connection failed). Check your network, DNS, or proxy settings. [{chain}]"
270 )
271 } else if e.is_body() || e.is_decode() {
272 format!("Connection to {host} lost mid-response — usually transient; try again. [{chain}]")
273 } else {
274 format!("Network error talking to {host}: {chain}")
275 }
276}
277
278pub type Result<T> = std::result::Result<T, RuntimeError>;
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 const KIMI_403_BODY: &str = r#"{"error":{"type":"access_terminated_error","message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}"#;
285
286 #[test]
287 fn proxy_labels_never_contain_colon_and_are_superset_of_anthropic() {
288 for label in VETTED_PROXY_ERROR_TYPES {
289 assert!(
290 !label.contains(':'),
291 "label {label:?} would be truncated by the engine"
292 );
293 assert!(
294 !label.contains('['),
295 "label {label:?} breaks the [label] suffix"
296 );
297 }
298 for label in VETTED_ERROR_TYPES {
299 assert!(
300 VETTED_PROXY_ERROR_TYPES.contains(label),
301 "proxy list must be a superset: missing {label}"
302 );
303 }
304 }
305
306 #[test]
307 fn proxy_label_reads_error_type() {
308 assert_eq!(
309 vetted_proxy_error_label(KIMI_403_BODY),
310 Some("access_terminated_error")
311 );
312 }
313
314 #[test]
315 fn proxy_label_falls_back_to_error_code_then_top_level() {
316 assert_eq!(
317 vetted_proxy_error_label(r#"{"error":{"code":"invalid_api_key","message":"x"}}"#),
318 Some("invalid_api_key")
319 );
320 assert_eq!(
322 vetted_proxy_error_label(r#"{"error":{"type":"weird","code":"insufficient_quota"}}"#),
323 Some("insufficient_quota")
324 );
325 assert_eq!(
326 vetted_proxy_error_label(r#"{"type":"server_error"}"#),
327 Some("server_error")
328 );
329 assert_eq!(
330 vetted_proxy_error_label(r#"{"code":"model_not_found"}"#),
331 Some("model_not_found")
332 );
333 }
334
335 #[test]
336 fn proxy_label_rejects_non_string_non_json_and_hostile_values() {
337 assert_eq!(vetted_proxy_error_label(r#"{"error":{"type":42}}"#), None);
338 assert_eq!(
339 vetted_proxy_error_label(r#"{"error":{"code":["a"]}}"#),
340 None
341 );
342 assert_eq!(
343 vetted_proxy_error_label(r#"{"error":{"type":"<script>ECHO:secret"}}"#),
344 None
345 );
346 assert_eq!(
348 vetted_proxy_error_label(r#"{"error":{"type":"access_terminated_error2"}}"#),
349 None
350 );
351 assert_eq!(
352 vetted_proxy_error_label(r#"{"error":{"type":"Access_Terminated_Error"}}"#),
353 None
354 );
355 assert_eq!(vetted_proxy_error_label("<html>403 Forbidden</html>"), None);
356 assert_eq!(vetted_proxy_error_label(""), None);
357 assert_eq!(vetted_proxy_error_label(&KIMI_403_BODY[..40]), None);
359 }
360
361 #[test]
362 fn humanize_proxy_403_access_terminated_is_quota_not_auth() {
363 let msg = humanize_proxy_status_error("kimi-code", 403, Some("access_terminated_error"))
364 .expect("known combo");
365 assert!(
366 msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
367 "got: {msg}"
368 );
369 assert!(msg.contains("/model"), "got: {msg}");
370 assert!(msg.contains("weekly"), "kimi-specific hint missing: {msg}");
371 assert!(msg.contains("membership page"), "got: {msg}");
372 assert!(
373 !msg.contains("login"),
374 "quota must not be misreported as auth: {msg}"
375 );
376
377 let generic = humanize_proxy_status_error("xai-auth", 403, Some("access_terminated_error"))
378 .expect("known combo");
379 assert!(
380 generic.starts_with("xai-auth: usage quota exhausted (HTTP 403)"),
381 "got: {generic}"
382 );
383 assert!(
384 !generic.contains("weekly"),
385 "kimi hint leaked to other provider: {generic}"
386 );
387 }
388
389 #[test]
390 fn humanize_proxy_401_points_to_provider_login() {
391 for label in [
392 "invalid_authentication_error",
393 "authentication_error",
394 "invalid_api_key",
395 ] {
396 let msg = humanize_proxy_status_error("xai-auth", 401, Some(label)).expect(label);
397 assert!(msg.contains("HTTP 401"), "got: {msg}");
398 assert!(
399 msg.contains("synaps login --provider xai-auth"),
400 "got: {msg}"
401 );
402 }
403 }
404
405 #[test]
406 fn humanize_proxy_429_distinguishes_rate_limit_from_quota() {
407 let rl = humanize_proxy_status_error("groq", 429, Some("rate_limit_error")).unwrap();
408 let q1 = humanize_proxy_status_error("groq", 429, Some("insufficient_quota")).unwrap();
409 let q2 = humanize_proxy_status_error("groq", 429, Some("quota_exceeded")).unwrap();
410 assert!(rl.contains("rate limited"), "got: {rl}");
411 assert!(q1.contains("quota exhausted"), "got: {q1}");
412 assert_eq!(q1, q2);
413 assert_ne!(rl, q1);
414 }
415
416 #[test]
417 fn humanize_proxy_unknown_combos_return_none() {
418 assert_eq!(humanize_proxy_status_error("kimi-code", 403, None), None);
419 assert_eq!(
420 humanize_proxy_status_error("kimi-code", 403, Some("rate_limit_error")),
421 None
422 );
423 assert_eq!(
424 humanize_proxy_status_error("kimi-code", 500, Some("server_error")),
425 None
426 );
427 assert_eq!(
428 humanize_proxy_status_error("kimi-code", 401, Some("access_terminated_error")),
429 None
430 );
431 assert_eq!(
433 humanize_proxy_status_error("kimi-code", 403, Some("<script>ECHO")),
434 None
435 );
436 }
437
438 #[test]
439 fn test_humanize_529_overloaded() {
440 let msg = humanize_api_error(
441 529,
442 r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
443 );
444 assert!(msg.contains("overloaded"), "got: {msg}");
445 assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
446 }
447
448 #[test]
449 fn test_humanize_401_points_to_login() {
450 let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
451 assert!(msg.contains("synaps login"), "got: {msg}");
452 }
453
454 #[test]
455 fn test_humanize_400_context_suggests_compact() {
456 let msg = humanize_api_error(
457 400,
458 r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#,
459 );
460 assert!(msg.contains("/compact"), "got: {msg}");
461 }
462
463 #[test]
464 fn test_humanize_400_many_image_names_the_limit() {
465 let msg = humanize_api_error(
466 400,
467 r#"{"type":"error","error":{"type":"invalid_request_error","message":"messages.433.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}"#,
468 );
469 assert!(msg.contains("20 images"), "got: {msg}");
470 assert!(msg.contains("2000 px"), "got: {msg}");
471 assert!(msg.contains("/compact"), "got: {msg}");
472 assert!(
473 !msg.contains("messages.433"),
474 "untrusted detail leaked: {msg}"
475 );
476 }
477
478 #[test]
479 fn test_humanize_400_cache_ttl_names_config_key() {
480 let msg = humanize_api_error(
481 400,
482 r#"{"error":{"message":"The extended-cache-ttl-2025-04-11 beta is not enabled for this account"}}"#,
483 );
484 assert!(msg.contains("cache_ttl = 5m"), "got: {msg}");
485 }
486
487 #[test]
488 fn test_humanize_unknown_status_names_status_but_withholds_detail() {
489 let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
492 assert!(msg.contains("418"), "got: {msg}");
493 assert!(!msg.contains("teapot"), "untrusted detail leaked: {msg}");
494 }
495
496 #[test]
497 fn test_humanize_non_json_body_truncated() {
498 let long_body = "x".repeat(500);
499 let msg = humanize_api_error(418, &long_body);
500 assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
501 }
502
503 #[test]
509 fn test_humanize_multibyte_boundary_no_panic() {
510 let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
512 assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
513 let msg = humanize_api_error(418, &body);
515 assert!(msg.contains("418"), "status should appear: {msg}");
518 assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
522 }
523
524 #[derive(Debug)]
527 struct Outer(Inner);
528 #[derive(Debug)]
529 struct Inner;
530
531 impl std::fmt::Display for Outer {
532 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
533 write!(f, "error sending request for url (https://example.com/x)")
534 }
535 }
536 impl std::fmt::Display for Inner {
537 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
538 write!(f, "operation timed out")
539 }
540 }
541 impl std::error::Error for Outer {
542 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
543 Some(&self.0)
544 }
545 }
546 impl std::error::Error for Inner {}
547
548 #[test]
549 fn error_chain_string_joins_all_sources() {
550 let msg = error_chain_string(&Outer(Inner));
551 assert!(
552 msg.contains("error sending request") && msg.contains("operation timed out"),
553 "chain must include top-level AND source: {msg}"
554 );
555 }
556
557 #[test]
558 fn error_chain_string_single_level_has_no_separator_suffix() {
559 let msg = error_chain_string(&Inner);
560 assert_eq!(msg, "operation timed out");
561 }
562
563 #[tokio::test]
568 async fn humanize_provider_timeout_names_host_and_says_transient() {
569 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
570 let addr = listener.local_addr().unwrap();
571 let client = reqwest::Client::builder()
573 .timeout(std::time::Duration::from_millis(100))
574 .build()
575 .unwrap();
576 let err = client
577 .post(format!("http://{addr}/codex/responses"))
578 .send()
579 .await
580 .expect_err("must time out");
581 drop(listener);
582 assert!(err.is_timeout(), "precondition: {err:?}");
583 let msg = humanize_provider_network_error(&err);
584 assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
585 assert!(msg.contains("timed out"), "must say timed out: {msg}");
586 assert!(msg.contains("transient"), "must flag transience: {msg}");
587 assert!(
588 !msg.contains("api.anthropic.com"),
589 "must not claim the Anthropic host: {msg}"
590 );
591 }
592
593 #[tokio::test]
594 async fn humanize_provider_connect_error_names_host() {
595 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
597 let addr = listener.local_addr().unwrap();
598 drop(listener);
599 let client = reqwest::Client::new();
600 let err = client
601 .post(format!("http://{addr}/codex/responses"))
602 .send()
603 .await
604 .expect_err("must fail to connect");
605 assert!(err.is_connect(), "precondition: {err:?}");
606 let msg = humanize_provider_network_error(&err);
607 assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
608 assert!(
609 msg.contains("Could not reach"),
610 "must describe connection failure: {msg}"
611 );
612 }
613
614 const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
623
624 fn hostile_json_body() -> String {
626 format!(
627 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\":{{}}}}}}]}}"}}}}"#,
628 HOSTILE_SENTINEL
629 )
630 }
631
632 fn assert_no_request_content(msg: &str, ctx: &str) {
634 assert!(
635 !msg.contains(HOSTILE_SENTINEL),
636 "{ctx}: sentinel leaked: {msg}"
637 );
638 assert!(!msg.contains("ECHOED"), "{ctx}: echoed body leaked: {msg}");
639 assert!(
640 !msg.contains("input_schema") && !msg.contains("secret system prompt"),
641 "{ctx}: request-shaped content leaked: {msg}"
642 );
643 }
644
645 #[test]
646 fn hostile_error_message_never_leaks_for_any_status() {
647 let body = hostile_json_body();
648 for status in [400u16, 403, 404, 429, 500, 418] {
649 let msg = humanize_api_error(status, &body);
650 assert_no_request_content(&msg, &format!("status {status}"));
651 assert!(
653 msg.contains(&status.to_string())
654 || msg.contains("Rate limited")
655 || msg.contains("Access denied")
656 || msg.contains("not found"),
657 "status {status}: message not actionable: {msg}"
658 );
659 }
660 }
661
662 #[test]
663 fn hostile_429_with_reset_hint_keeps_timing_but_not_body() {
664 let msg = humanize_api_error_with_reset(429, &hostile_json_body(), Some("47s"));
665 assert_no_request_content(&msg, "429+reset");
666 assert!(msg.contains("47s"), "reset timing must survive: {msg}");
667 }
668
669 #[test]
670 fn hostile_non_json_body_never_leaks() {
671 let body = format!(
673 "raw echo: {} {{\"messages\":[...],\"system\":[...]}} {}",
674 HOSTILE_SENTINEL,
675 "x".repeat(300)
676 );
677 for status in [400u16, 403, 404, 429, 500, 418] {
678 let msg = humanize_api_error(status, &body);
679 assert_no_request_content(&msg, &format!("non-json status {status}"));
680 }
681 }
682
683 #[test]
684 fn hostile_error_type_field_is_not_echoed_verbatim() {
685 let body = format!(
688 r#"{{"error":{{"type":"{} injected-type","message":"m"}}}}"#,
689 HOSTILE_SENTINEL
690 );
691 for status in [400u16, 403, 404, 429, 500, 418] {
692 let msg = humanize_api_error(status, &body);
693 assert_no_request_content(&msg, &format!("type-field status {status}"));
694 }
695 }
696
697 #[test]
698 fn test_runtime_error_display() {
699 assert_eq!(
700 format!("{}", RuntimeError::Auth("bad token".into())),
701 "Auth error: bad token"
702 );
703
704 assert_eq!(
705 format!("{}", RuntimeError::Config("missing".into())),
706 "Config error: missing"
707 );
708
709 assert_eq!(
710 format!("{}", RuntimeError::Tool("failed".into())),
711 "Tool execution failed: failed"
712 );
713
714 assert_eq!(
715 format!("{}", RuntimeError::Session("not found".into())),
716 "Session error: not found"
717 );
718
719 assert_eq!(format!("{}", RuntimeError::Timeout), "Request timed out");
720
721 assert_eq!(format!("{}", RuntimeError::Canceled), "Operation canceled");
722 }
723
724 #[test]
725 fn test_runtime_error_to_string() {
726 assert_eq!(
727 RuntimeError::Auth("bad token".into()).to_string(),
728 "Auth error: bad token"
729 );
730
731 assert_eq!(
732 RuntimeError::Config("missing".into()).to_string(),
733 "Config error: missing"
734 );
735
736 assert_eq!(
737 RuntimeError::Tool("failed".into()).to_string(),
738 "Tool execution failed: failed"
739 );
740
741 assert_eq!(
742 RuntimeError::Session("not found".into()).to_string(),
743 "Session error: not found"
744 );
745
746 assert_eq!(RuntimeError::Timeout.to_string(), "Request timed out");
747
748 assert_eq!(RuntimeError::Canceled.to_string(), "Operation canceled");
749 }
750}