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