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