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 {
29 let api_msg = serde_json::from_str::<serde_json::Value>(body)
31 .ok()
32 .and_then(|v| {
33 v.get("error")
34 .and_then(|e| e.get("message"))
35 .and_then(|m| m.as_str())
36 .map(String::from)
37 });
38 let detail = api_msg.unwrap_or_else(|| {
39 let trimmed = body.trim();
40 if trimmed.len() > 200 { format!("{}…", &trimmed[..200]) } else { trimmed.to_string() }
41 });
42
43 match status {
44 529 => "Anthropic is overloaded right now. Retries exhausted — wait a minute and try again.".to_string(),
45 429 => format!("Rate limited by Anthropic ({}). Wait for the limit to reset, or switch models with /model.", detail),
46 401 => "Authentication rejected. Run `synaps login` to re-authenticate, or check ANTHROPIC_API_KEY.".to_string(),
47 403 => format!("Access denied ({}). Your account may not have access to this model.", detail),
48 404 => format!("Model or endpoint not found ({}). Check the model name with /model.", detail),
49 413 => "Request too large. Run /compact to shrink the conversation, or reduce tool output sizes.".to_string(),
50 400 if detail.contains("prompt is too long") || detail.contains("max_tokens") || detail.contains("context") =>
51 format!("Context window exceeded ({}). Run /compact to shrink the conversation.", detail),
52 500 | 502 | 503 => format!("Anthropic server error ({} {}). Retries exhausted — usually transient, try again shortly.", status, detail),
53 _ => format!("API error {} — {}", status, detail),
54 }
55}
56
57pub fn humanize_network_error(e: &reqwest::Error) -> String {
59 if e.is_timeout() {
60 "Request to api.anthropic.com timed out. Check your connection and try again.".to_string()
61 } else if e.is_connect() {
62 "Could not reach api.anthropic.com (connection failed). Check your network, DNS, or proxy settings.".to_string()
63 } else if e.is_body() || e.is_decode() {
64 "Connection lost mid-response. Partial reply kept — send again to continue.".to_string()
65 } else {
66 format!("Network error: {}", e)
67 }
68}
69
70pub type Result<T> = std::result::Result<T, RuntimeError>;
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn test_humanize_529_overloaded() {
78 let msg = humanize_api_error(529, r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#);
79 assert!(msg.contains("overloaded"), "got: {msg}");
80 assert!(!msg.contains('{'), "raw JSON leaked: {msg}");
81 }
82
83 #[test]
84 fn test_humanize_401_points_to_login() {
85 let msg = humanize_api_error(401, r#"{"error":{"message":"invalid x-api-key"}}"#);
86 assert!(msg.contains("synaps login"), "got: {msg}");
87 }
88
89 #[test]
90 fn test_humanize_400_context_suggests_compact() {
91 let msg = humanize_api_error(400, r#"{"error":{"message":"prompt is too long: 250000 tokens"}}"#);
92 assert!(msg.contains("/compact"), "got: {msg}");
93 }
94
95 #[test]
96 fn test_humanize_unknown_status_includes_detail() {
97 let msg = humanize_api_error(418, r#"{"error":{"message":"teapot"}}"#);
98 assert!(msg.contains("418") && msg.contains("teapot"), "got: {msg}");
99 }
100
101 #[test]
102 fn test_humanize_non_json_body_truncated() {
103 let long_body = "x".repeat(500);
104 let msg = humanize_api_error(418, &long_body);
105 assert!(msg.len() < 300, "not truncated: {} chars", msg.len());
106 }
107
108 #[test]
109 fn test_runtime_error_display() {
110 assert_eq!(
111 format!("{}", RuntimeError::Auth("bad token".into())),
112 "Auth error: bad token"
113 );
114
115 assert_eq!(
116 format!("{}", RuntimeError::Config("missing".into())),
117 "Config error: missing"
118 );
119
120 assert_eq!(
121 format!("{}", RuntimeError::Tool("failed".into())),
122 "Tool execution failed: failed"
123 );
124
125 assert_eq!(
126 format!("{}", RuntimeError::Session("not found".into())),
127 "Session error: not found"
128 );
129
130 assert_eq!(
131 format!("{}", RuntimeError::Timeout),
132 "Request timed out"
133 );
134
135 assert_eq!(
136 format!("{}", RuntimeError::Canceled),
137 "Operation canceled"
138 );
139 }
140
141 #[test]
142 fn test_runtime_error_to_string() {
143 assert_eq!(
144 RuntimeError::Auth("bad token".into()).to_string(),
145 "Auth error: bad token"
146 );
147
148 assert_eq!(
149 RuntimeError::Config("missing".into()).to_string(),
150 "Config error: missing"
151 );
152
153 assert_eq!(
154 RuntimeError::Tool("failed".into()).to_string(),
155 "Tool execution failed: failed"
156 );
157
158 assert_eq!(
159 RuntimeError::Session("not found".into()).to_string(),
160 "Session error: not found"
161 );
162
163 assert_eq!(
164 RuntimeError::Timeout.to_string(),
165 "Request timed out"
166 );
167
168 assert_eq!(
169 RuntimeError::Canceled.to_string(),
170 "Operation canceled"
171 );
172 }
173}