Skip to main content

agent_core/core/
error.rs

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
23/// Translate an Anthropic API error response into a human-actionable message.
24///
25/// Parses the error body (`{"error": {"type": ..., "message": ...}}`) and maps
26/// well-known statuses to guidance. Falls back to a trimmed version of the raw
27/// body for unknown cases.
28pub fn humanize_api_error(status: u16, body: &str) -> String {
29    humanize_api_error_with_reset(status, body, None)
30}
31
32/// Like [`humanize_api_error`] but surfaces a known rate-limit reset time in
33/// the 429 message so the failure is honest rather than cryptic.
34/// `reset_hint` is a human-readable duration string, e.g. `"47s"`.
35pub fn humanize_api_error_with_reset(status: u16, body: &str, reset_hint: Option<&str>) -> String {
36    // Pull the server's message out of the JSON envelope if present.
37    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
76/// Translate a reqwest transport error into a human-actionable message.
77pub 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}