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, "extended-cache-ttl") =>
201 "Bad request (HTTP 400) — your account may not support 1h cache TTL; set cache_ttl = 5m in config.".to_string(),
202 400 if body_mentions(body, "prompt is too long") || body_mentions(body, "max_tokens") || body_mentions(body, "context") =>
203 "Context window exceeded (HTTP 400). Run /compact to shrink the conversation.".to_string(),
204 400 => format!("Bad request (HTTP 400{kind}). Provider error details withheld — they can echo request content."),
205 500 | 502 | 503 => format!("Anthropic server error (HTTP {status}{kind}). Retries exhausted — usually transient, try again shortly."),
206 _ => format!("API error (HTTP {status}{kind}). Provider error details withheld — they can echo request content."),
207 }
208}
209
210pub fn humanize_network_error(e: &reqwest::Error) -> String {
212 if e.is_timeout() {
213 "Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
214 } else if e.is_connect() {
215 "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
216 } else if e.is_body() || e.is_decode() {
217 "Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
218 } else {
219 format!("Network error: {}", e)
220 }
221}
222
223pub fn error_chain_string(e: &(dyn std::error::Error + 'static)) -> String {
230 let mut msg = e.to_string();
231 let mut source = e.source();
232 while let Some(cause) = source {
233 let cause_str = cause.to_string();
234 if !msg.contains(&cause_str) {
236 msg.push_str(": ");
237 msg.push_str(&cause_str);
238 }
239 source = cause.source();
240 }
241 msg
242}
243
244pub fn humanize_provider_network_error(e: &reqwest::Error) -> String {
252 let host = e
253 .url()
254 .and_then(|u| u.host_str())
255 .map(String::from)
256 .unwrap_or_else(|| "the provider endpoint".to_string());
257 let chain = error_chain_string(e);
258 if e.is_timeout() {
259 format!(
260 "Request to {host} timed out — usually transient; check your connection and try again. [{chain}]"
261 )
262 } else if e.is_connect() {
263 format!(
264 "Could not reach {host} (connection failed). Check your network, DNS, or proxy settings. [{chain}]"
265 )
266 } else if e.is_body() || e.is_decode() {
267 format!("Connection to {host} lost mid-response — usually transient; try again. [{chain}]")
268 } else {
269 format!("Network error talking to {host}: {chain}")
270 }
271}
272
273pub type Result<T> = std::result::Result<T, RuntimeError>;
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 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."}}"#;
280
281 #[test]
282 fn proxy_labels_never_contain_colon_and_are_superset_of_anthropic() {
283 for label in VETTED_PROXY_ERROR_TYPES {
284 assert!(
285 !label.contains(':'),
286 "label {label:?} would be truncated by the engine"
287 );
288 assert!(
289 !label.contains('['),
290 "label {label:?} breaks the [label] suffix"
291 );
292 }
293 for label in VETTED_ERROR_TYPES {
294 assert!(
295 VETTED_PROXY_ERROR_TYPES.contains(label),
296 "proxy list must be a superset: missing {label}"
297 );
298 }
299 }
300
301 #[test]
302 fn proxy_label_reads_error_type() {
303 assert_eq!(
304 vetted_proxy_error_label(KIMI_403_BODY),
305 Some("access_terminated_error")
306 );
307 }
308
309 #[test]
310 fn proxy_label_falls_back_to_error_code_then_top_level() {
311 assert_eq!(
312 vetted_proxy_error_label(r#"{"error":{"code":"invalid_api_key","message":"x"}}"#),
313 Some("invalid_api_key")
314 );
315 assert_eq!(
317 vetted_proxy_error_label(r#"{"error":{"type":"weird","code":"insufficient_quota"}}"#),
318 Some("insufficient_quota")
319 );
320 assert_eq!(
321 vetted_proxy_error_label(r#"{"type":"server_error"}"#),
322 Some("server_error")
323 );
324 assert_eq!(
325 vetted_proxy_error_label(r#"{"code":"model_not_found"}"#),
326 Some("model_not_found")
327 );
328 }
329
330 #[test]
331 fn proxy_label_rejects_non_string_non_json_and_hostile_values() {
332 assert_eq!(vetted_proxy_error_label(r#"{"error":{"type":42}}"#), None);
333 assert_eq!(
334 vetted_proxy_error_label(r#"{"error":{"code":["a"]}}"#),
335 None
336 );
337 assert_eq!(
338 vetted_proxy_error_label(r#"{"error":{"type":"<script>ECHO:secret"}}"#),
339 None
340 );
341 assert_eq!(
343 vetted_proxy_error_label(r#"{"error":{"type":"access_terminated_error2"}}"#),
344 None
345 );
346 assert_eq!(
347 vetted_proxy_error_label(r#"{"error":{"type":"Access_Terminated_Error"}}"#),
348 None
349 );
350 assert_eq!(vetted_proxy_error_label("<html>403 Forbidden</html>"), None);
351 assert_eq!(vetted_proxy_error_label(""), None);
352 assert_eq!(vetted_proxy_error_label(&KIMI_403_BODY[..40]), None);
354 }
355
356 #[test]
357 fn humanize_proxy_403_access_terminated_is_quota_not_auth() {
358 let msg = humanize_proxy_status_error("kimi-code", 403, Some("access_terminated_error"))
359 .expect("known combo");
360 assert!(
361 msg.starts_with("kimi-code: usage quota exhausted (HTTP 403)"),
362 "got: {msg}"
363 );
364 assert!(msg.contains("/model"), "got: {msg}");
365 assert!(msg.contains("weekly"), "kimi-specific hint missing: {msg}");
366 assert!(msg.contains("membership page"), "got: {msg}");
367 assert!(
368 !msg.contains("login"),
369 "quota must not be misreported as auth: {msg}"
370 );
371
372 let generic = humanize_proxy_status_error("xai-auth", 403, Some("access_terminated_error"))
373 .expect("known combo");
374 assert!(
375 generic.starts_with("xai-auth: usage quota exhausted (HTTP 403)"),
376 "got: {generic}"
377 );
378 assert!(
379 !generic.contains("weekly"),
380 "kimi hint leaked to other provider: {generic}"
381 );
382 }
383
384 #[test]
385 fn humanize_proxy_401_points_to_provider_login() {
386 for label in [
387 "invalid_authentication_error",
388 "authentication_error",
389 "invalid_api_key",
390 ] {
391 let msg = humanize_proxy_status_error("xai-auth", 401, Some(label)).expect(label);
392 assert!(msg.contains("HTTP 401"), "got: {msg}");
393 assert!(
394 msg.contains("synaps login --provider xai-auth"),
395 "got: {msg}"
396 );
397 }
398 }
399
400 #[test]
401 fn humanize_proxy_429_distinguishes_rate_limit_from_quota() {
402 let rl = humanize_proxy_status_error("groq", 429, Some("rate_limit_error")).unwrap();
403 let q1 = humanize_proxy_status_error("groq", 429, Some("insufficient_quota")).unwrap();
404 let q2 = humanize_proxy_status_error("groq", 429, Some("quota_exceeded")).unwrap();
405 assert!(rl.contains("rate limited"), "got: {rl}");
406 assert!(q1.contains("quota exhausted"), "got: {q1}");
407 assert_eq!(q1, q2);
408 assert_ne!(rl, q1);
409 }
410
411 #[test]
412 fn humanize_proxy_unknown_combos_return_none() {
413 assert_eq!(humanize_proxy_status_error("kimi-code", 403, None), None);
414 assert_eq!(
415 humanize_proxy_status_error("kimi-code", 403, Some("rate_limit_error")),
416 None
417 );
418 assert_eq!(
419 humanize_proxy_status_error("kimi-code", 500, Some("server_error")),
420 None
421 );
422 assert_eq!(
423 humanize_proxy_status_error("kimi-code", 401, Some("access_terminated_error")),
424 None
425 );
426 assert_eq!(
428 humanize_proxy_status_error("kimi-code", 403, Some("<script>ECHO")),
429 None
430 );
431 }
432
433 #[test]
434 fn test_humanize_529_overloaded() {
435 let msg = humanize_api_error(
436 529,
437 r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
438 );
439 assert!(msg.contains("overloaded"), "got: {msg}");
440 assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
441 }
442
443 #[test]
444 fn test_humanize_401_points_to_login() {
445 let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
446 assert!(msg.contains("synaps login"), "got: {msg}");
447 }
448
449 #[test]
450 fn test_humanize_400_context_suggests_compact() {
451 let msg = humanize_api_error(
452 400,
453 r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#,
454 );
455 assert!(msg.contains("/compact"), "got: {msg}");
456 }
457
458 #[test]
459 fn test_humanize_400_cache_ttl_names_config_key() {
460 let msg = humanize_api_error(
461 400,
462 r#"{"error":{"message":"The extended-cache-ttl-2025-04-11 beta is not enabled for this account"}}"#,
463 );
464 assert!(msg.contains("cache_ttl = 5m"), "got: {msg}");
465 }
466
467 #[test]
468 fn test_humanize_unknown_status_names_status_but_withholds_detail() {
469 let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
472 assert!(msg.contains("418"), "got: {msg}");
473 assert!(!msg.contains("teapot"), "untrusted detail leaked: {msg}");
474 }
475
476 #[test]
477 fn test_humanize_non_json_body_truncated() {
478 let long_body = "x".repeat(500);
479 let msg = humanize_api_error(418, &long_body);
480 assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
481 }
482
483 #[test]
489 fn test_humanize_multibyte_boundary_no_panic() {
490 let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
492 assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
493 let msg = humanize_api_error(418, &body);
495 assert!(msg.contains("418"), "status should appear: {msg}");
498 assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
502 }
503
504 #[derive(Debug)]
507 struct Outer(Inner);
508 #[derive(Debug)]
509 struct Inner;
510
511 impl std::fmt::Display for Outer {
512 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513 write!(f, "error sending request for url (https://example.com/x)")
514 }
515 }
516 impl std::fmt::Display for Inner {
517 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
518 write!(f, "operation timed out")
519 }
520 }
521 impl std::error::Error for Outer {
522 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
523 Some(&self.0)
524 }
525 }
526 impl std::error::Error for Inner {}
527
528 #[test]
529 fn error_chain_string_joins_all_sources() {
530 let msg = error_chain_string(&Outer(Inner));
531 assert!(
532 msg.contains("error sending request") && msg.contains("operation timed out"),
533 "chain must include top-level AND source: {msg}"
534 );
535 }
536
537 #[test]
538 fn error_chain_string_single_level_has_no_separator_suffix() {
539 let msg = error_chain_string(&Inner);
540 assert_eq!(msg, "operation timed out");
541 }
542
543 #[tokio::test]
548 async fn humanize_provider_timeout_names_host_and_says_transient() {
549 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
550 let addr = listener.local_addr().unwrap();
551 let client = reqwest::Client::builder()
553 .timeout(std::time::Duration::from_millis(100))
554 .build()
555 .unwrap();
556 let err = client
557 .post(format!("http://{addr}/codex/responses"))
558 .send()
559 .await
560 .expect_err("must time out");
561 drop(listener);
562 assert!(err.is_timeout(), "precondition: {err:?}");
563 let msg = humanize_provider_network_error(&err);
564 assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
565 assert!(msg.contains("timed out"), "must say timed out: {msg}");
566 assert!(msg.contains("transient"), "must flag transience: {msg}");
567 assert!(
568 !msg.contains("api.anthropic.com"),
569 "must not claim the Anthropic host: {msg}"
570 );
571 }
572
573 #[tokio::test]
574 async fn humanize_provider_connect_error_names_host() {
575 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
577 let addr = listener.local_addr().unwrap();
578 drop(listener);
579 let client = reqwest::Client::new();
580 let err = client
581 .post(format!("http://{addr}/codex/responses"))
582 .send()
583 .await
584 .expect_err("must fail to connect");
585 assert!(err.is_connect(), "precondition: {err:?}");
586 let msg = humanize_provider_network_error(&err);
587 assert!(msg.contains("127.0.0.1"), "must name the host: {msg}");
588 assert!(
589 msg.contains("Could not reach"),
590 "must describe connection failure: {msg}"
591 );
592 }
593
594 const HOSTILE_SENTINEL: &str = "HOLDOUT-SENTINEL-http500-77aa";
603
604 fn hostile_json_body() -> String {
606 format!(
607 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\":{{}}}}}}]}}"}}}}"#,
608 HOSTILE_SENTINEL
609 )
610 }
611
612 fn assert_no_request_content(msg: &str, ctx: &str) {
614 assert!(
615 !msg.contains(HOSTILE_SENTINEL),
616 "{ctx}: sentinel leaked: {msg}"
617 );
618 assert!(!msg.contains("ECHOED"), "{ctx}: echoed body leaked: {msg}");
619 assert!(
620 !msg.contains("input_schema") && !msg.contains("secret system prompt"),
621 "{ctx}: request-shaped content leaked: {msg}"
622 );
623 }
624
625 #[test]
626 fn hostile_error_message_never_leaks_for_any_status() {
627 let body = hostile_json_body();
628 for status in [400u16, 403, 404, 429, 500, 418] {
629 let msg = humanize_api_error(status, &body);
630 assert_no_request_content(&msg, &format!("status {status}"));
631 assert!(
633 msg.contains(&status.to_string())
634 || msg.contains("Rate limited")
635 || msg.contains("Access denied")
636 || msg.contains("not found"),
637 "status {status}: message not actionable: {msg}"
638 );
639 }
640 }
641
642 #[test]
643 fn hostile_429_with_reset_hint_keeps_timing_but_not_body() {
644 let msg = humanize_api_error_with_reset(429, &hostile_json_body(), Some("47s"));
645 assert_no_request_content(&msg, "429+reset");
646 assert!(msg.contains("47s"), "reset timing must survive: {msg}");
647 }
648
649 #[test]
650 fn hostile_non_json_body_never_leaks() {
651 let body = format!(
653 "raw echo: {} {{\"messages\":[...],\"system\":[...]}} {}",
654 HOSTILE_SENTINEL,
655 "x".repeat(300)
656 );
657 for status in [400u16, 403, 404, 429, 500, 418] {
658 let msg = humanize_api_error(status, &body);
659 assert_no_request_content(&msg, &format!("non-json status {status}"));
660 }
661 }
662
663 #[test]
664 fn hostile_error_type_field_is_not_echoed_verbatim() {
665 let body = format!(
668 r#"{{"error":{{"type":"{} injected-type","message":"m"}}}}"#,
669 HOSTILE_SENTINEL
670 );
671 for status in [400u16, 403, 404, 429, 500, 418] {
672 let msg = humanize_api_error(status, &body);
673 assert_no_request_content(&msg, &format!("type-field status {status}"));
674 }
675 }
676
677 #[test]
678 fn test_runtime_error_display() {
679 assert_eq!(
680 format!("{}", RuntimeError::Auth("bad token".into())),
681 "Auth error: bad token"
682 );
683
684 assert_eq!(
685 format!("{}", RuntimeError::Config("missing".into())),
686 "Config error: missing"
687 );
688
689 assert_eq!(
690 format!("{}", RuntimeError::Tool("failed".into())),
691 "Tool execution failed: failed"
692 );
693
694 assert_eq!(
695 format!("{}", RuntimeError::Session("not found".into())),
696 "Session error: not found"
697 );
698
699 assert_eq!(format!("{}", RuntimeError::Timeout), "Request timed out");
700
701 assert_eq!(format!("{}", RuntimeError::Canceled), "Operation canceled");
702 }
703
704 #[test]
705 fn test_runtime_error_to_string() {
706 assert_eq!(
707 RuntimeError::Auth("bad token".into()).to_string(),
708 "Auth error: bad token"
709 );
710
711 assert_eq!(
712 RuntimeError::Config("missing".into()).to_string(),
713 "Config error: missing"
714 );
715
716 assert_eq!(
717 RuntimeError::Tool("failed".into()).to_string(),
718 "Tool execution failed: failed"
719 );
720
721 assert_eq!(
722 RuntimeError::Session("not found".into()).to_string(),
723 "Session error: not found"
724 );
725
726 assert_eq!(RuntimeError::Timeout.to_string(), "Request timed out");
727
728 assert_eq!(RuntimeError::Canceled.to_string(), "Operation canceled");
729 }
730}