Skip to main content

synaps_cli/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    // Pull the server's message out of the JSON envelope if present.
30    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
57/// Translate a reqwest transport error into a human-actionable message.
58pub 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}