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!("{}…", crate::truncate_str(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    /// BUG-1 regression: byte-slicing at offset 200 panics when a multibyte
134    /// char (e.g. the 4-byte emoji 🔥) straddles that boundary.
135    /// Craft a body where the emoji starts at byte 198 (i.e. bytes 198-201),
136    /// so `&trimmed[..200]` would land in the middle of a char → panic.
137    /// The fixed code must NOT panic and must truncate cleanly at a char boundary.
138    #[test]
139    fn test_humanize_multibyte_boundary_no_panic() {
140        // 198 ASCII bytes + 🔥 (4 bytes, positions 198-201) + filler to exceed 200 total
141        let body = format!("{}{}{}", "a".repeat(198), "🔥", "b".repeat(100));
142        assert!(body.len() > 200, "precondition: body must exceed 200 bytes");
143        // This must NOT panic — the bug causes a panic on unpatched code
144        let msg = humanize_api_error(418, &body);
145        // The result must be valid UTF-8 (it's a &str / String, always is if no panic)
146        // and must contain the truncated prefix (not the raw emoji bytes mid-char)
147        assert!(msg.contains("418"), "status should appear: {msg}");
148        // The body portion forwarded must be ≤ 200 bytes (emoji trimmed at boundary)
149        // After truncation at 198 bytes (the last safe boundary before 200), we get
150        // 198 'a's — confirm no garbled bytes leaked through
151        assert!(!msg.contains('\u{FFFD}'), "replacement char leaked: {msg}");
152    }
153
154    #[test]
155    fn test_runtime_error_display() {
156        assert_eq!(
157            format!("{}", RuntimeError::Auth("bad token".into())),
158            "Auth error: bad token"
159        );
160
161        assert_eq!(
162            format!("{}", RuntimeError::Config("missing".into())),
163            "Config error: missing"
164        );
165
166        assert_eq!(
167            format!("{}", RuntimeError::Tool("failed".into())),
168            "Tool execution failed: failed"
169        );
170
171        assert_eq!(
172            format!("{}", RuntimeError::Session("not found".into())),
173            "Session error: not found"
174        );
175
176        assert_eq!(
177            format!("{}", RuntimeError::Timeout),
178            "Request timed out"
179        );
180
181        assert_eq!(
182            format!("{}", RuntimeError::Canceled),
183            "Operation canceled"
184        );
185    }
186
187    #[test]
188    fn test_runtime_error_to_string() {
189        assert_eq!(
190            RuntimeError::Auth("bad token".into()).to_string(),
191            "Auth error: bad token"
192        );
193
194        assert_eq!(
195            RuntimeError::Config("missing".into()).to_string(),
196            "Config error: missing"
197        );
198
199        assert_eq!(
200            RuntimeError::Tool("failed".into()).to_string(),
201            "Tool execution failed: failed"
202        );
203
204        assert_eq!(
205            RuntimeError::Session("not found".into()).to_string(),
206            "Session error: not found"
207        );
208
209        assert_eq!(
210            RuntimeError::Timeout.to_string(),
211            "Request timed out"
212        );
213
214        assert_eq!(
215            RuntimeError::Canceled.to_string(),
216            "Operation canceled"
217        );
218    }
219}