Skip to main content

ai_agent/services/api/
error_utils.rs

1// Source: /data/home/swei/claudecode/openclaudecode/src/services/api/errorUtils.ts
2//! API error utilities
3//! Extracts connection error details and formats API errors
4
5use std::collections::HashSet;
6
7/// SSL/TLS error codes from OpenSSL
8static SSL_ERROR_CODES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
9    let mut set = HashSet::new();
10    // Certificate verification errors
11    set.insert("UNABLE_TO_VERIFY_LEAF_SIGNATURE");
12    set.insert("UNABLE_TO_GET_ISSUER_CERT");
13    set.insert("UNABLE_TO_GET_ISSUER_CERT_LOCALLY");
14    set.insert("CERT_SIGNATURE_FAILURE");
15    set.insert("CERT_NOT_YET_VALID");
16    set.insert("CERT_HAS_EXPIRED");
17    set.insert("CERT_REVOKED");
18    set.insert("CERT_REJECTED");
19    set.insert("CERT_UNTRUSTED");
20    // Self-signed certificate errors
21    set.insert("DEPTH_ZERO_SELF_SIGNED_CERT");
22    set.insert("SELF_SIGNED_CERT_IN_CHAIN");
23    // Chain errors
24    set.insert("CERT_CHAIN_TOO_LONG");
25    set.insert("PATH_LENGTH_EXCEEDED");
26    // Hostname/altname errors
27    set.insert("ERR_TLS_CERT_ALTNAME_INVALID");
28    set.insert("HOSTNAME_MISMATCH");
29    // TLS handshake errors
30    set.insert("ERR_TLS_HANDSHAKE_TIMEOUT");
31    set.insert("ERR_SSL_WRONG_VERSION_NUMBER");
32    set.insert("ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC");
33    set
34});
35
36use once_cell::sync::Lazy;
37
38/// Connection error details
39#[derive(Debug, Clone)]
40pub struct ConnectionErrorDetails {
41    pub code: String,
42    pub message: String,
43    pub is_ssl_error: bool,
44}
45
46/// Extracts connection error details from an error message string
47pub fn extract_connection_error_details_from_message(msg: &str) -> Option<ConnectionErrorDetails> {
48    // Check for common error codes in the message
49    let lower = msg.to_lowercase();
50
51    // Check for timeout
52    if lower.contains("timed out") || lower.contains("etimedout") {
53        return Some(ConnectionErrorDetails {
54            code: "ETIMEDOUT".to_string(),
55            message: msg.to_string(),
56            is_ssl_error: false,
57        });
58    }
59
60    // Check for SSL/TLS errors
61    let is_ssl = lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate");
62    if is_ssl {
63        // Try to extract specific SSL code
64        let code = if lower.contains("self_signed") || lower.contains("self signed") {
65            "DEPTH_ZERO_SELF_SIGNED_CERT".to_string()
66        } else if lower.contains("certificate has expired") {
67            "CERT_HAS_EXPIRED".to_string()
68        } else if lower.contains("hostname") || lower.contains("altname") {
69            "ERR_TLS_CERT_ALTNAME_INVALID".to_string()
70        } else {
71            "SSL_ERROR".to_string()
72        };
73
74        return Some(ConnectionErrorDetails {
75            code,
76            message: msg.to_string(),
77            is_ssl_error: true,
78        });
79    }
80
81    // Check for connection reset
82    if lower.contains("econnreset") || lower.contains("connection reset") {
83        return Some(ConnectionErrorDetails {
84            code: "ECONNRESET".to_string(),
85            message: msg.to_string(),
86            is_ssl_error: false,
87        });
88    }
89
90    // Check for broken pipe
91    if lower.contains("epipe") || lower.contains("broken pipe") {
92        return Some(ConnectionErrorDetails {
93            code: "EPIPE".to_string(),
94            message: msg.to_string(),
95            is_ssl_error: false,
96        });
97    }
98
99    None
100}
101
102/// Returns an actionable hint for SSL/TLS errors
103pub fn get_ssl_error_hint(error_message: &str) -> Option<String> {
104    let details = extract_connection_error_details_from_message(error_message)?;
105    if !details.is_ssl_error {
106        return None;
107    }
108    Some(format!(
109        "SSL certificate error ({}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.",
110        details.code
111    ))
112}
113
114/// Strips HTML content (e.g., CloudFlare error pages) from a message string
115fn sanitize_message_html(message: &str) -> String {
116    let lower = message.to_lowercase();
117    if lower.contains("<!DOCTYPE html") || lower.contains("<html") {
118        // Case-insensitive check, but use original message with case-insensitive pattern matching
119        let title_pattern = regex::Regex::new("(?i)<title>([^<]+)</title>").ok();
120        if let Some(re) = title_pattern {
121            if let Some(caps) = re.captures(message) {
122                if let Some(title) = caps.get(1) {
123                    return title.as_str().trim().to_string();
124                }
125            }
126        }
127        return String::new();
128    }
129    message.to_string()
130}
131
132/// Detects if an error message contains HTML content
133pub fn sanitize_api_error(message: &str) -> String {
134    if message.is_empty() {
135        return String::new();
136    }
137    sanitize_message_html(message)
138}
139
140/// Shape of deserialized API errors from session JSONL
141#[derive(Debug, Clone)]
142pub struct NestedApiError {
143    pub message: Option<String>,
144    pub error: Option<Box<NestedApiError>>,
145}
146
147impl<'de> serde::Deserialize<'de> for NestedApiError {
148    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
149    where
150        D: serde::Deserializer<'de>,
151    {
152        let value = serde_json::Value::deserialize(deserializer)?;
153        let message = value
154            .get("message")
155            .and_then(|v| v.as_str())
156            .map(String::from);
157        let error = value.get("error").and_then(|v| {
158            v.as_object().map(|_| {
159                Box::new(NestedApiError {
160                    message: v.get("message").and_then(|v| v.as_str()).map(String::from),
161                    error: None,
162                })
163            })
164        });
165        Ok(NestedApiError { message, error })
166    }
167}
168
169/// Extract a human-readable message from a deserialized API error
170pub fn extract_nested_error_message(error: &serde_json::Value) -> Option<String> {
171    // Standard Anthropic API shape: { error: { error: { message } } }
172    if let Some(error_obj) = error.get("error") {
173        if let Some(inner_error) = error_obj.get("error") {
174            if let Some(msg) = inner_error.get("message").and_then(|v| v.as_str()) {
175                let sanitized = sanitize_message_html(msg);
176                if !sanitized.is_empty() {
177                    return Some(sanitized);
178                }
179            }
180        }
181        // Bedrock shape: { error: { message } }
182        if let Some(msg) = error_obj.get("message").and_then(|v| v.as_str()) {
183            let sanitized = sanitize_message_html(msg);
184            if !sanitized.is_empty() {
185                return Some(sanitized);
186            }
187        }
188    }
189    None
190}
191
192/// Format an API error for display
193pub fn format_api_error(error_message: &str) -> String {
194    // Extract connection error details from the message
195    let connection_details = extract_connection_error_details_from_message(error_message);
196
197    if let Some(ref details) = connection_details {
198        let code = &details.code;
199
200        // Handle timeout errors
201        if code == "ETIMEDOUT" {
202            return "Request timed out. Check your internet connection and proxy settings"
203                .to_string();
204        }
205
206        // Handle SSL/TLS errors with specific messages
207        if details.is_ssl_error {
208            match code.as_str() {
209                "UNABLE_TO_VERIFY_LEAF_SIGNATURE"
210                | "UNABLE_TO_GET_ISSUER_CERT"
211                | "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" => {
212                    return "Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates".to_string();
213                }
214                "CERT_HAS_EXPIRED" => {
215                    return "Unable to connect to API: SSL certificate has expired".to_string();
216                }
217                "CERT_REVOKED" => {
218                    return "Unable to connect to API: SSL certificate has been revoked"
219                        .to_string();
220                }
221                "DEPTH_ZERO_SELF_SIGNED_CERT" | "SELF_SIGNED_CERT_IN_CHAIN" => {
222                    return "Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates".to_string();
223                }
224                "ERR_TLS_CERT_ALTNAME_INVALID" | "HOSTNAME_MISMATCH" => {
225                    return "Unable to connect to API: SSL certificate hostname mismatch"
226                        .to_string();
227                }
228                "CERT_NOT_YET_VALID" => {
229                    return "Unable to connect to API: SSL certificate is not yet valid"
230                        .to_string();
231                }
232                _ => {
233                    return format!("Unable to connect to API: SSL error ({})", code);
234                }
235            }
236        }
237    }
238
239    if error_message == "Connection error." {
240        if let Some(details) = connection_details {
241            return format!("Unable to connect to API ({})", details.code);
242        }
243        return "Unable to connect to API. Check your internet connection".to_string();
244    }
245
246    if error_message.is_empty() {
247        return "API error (status unknown)".to_string();
248    }
249
250    let sanitized_message = sanitize_api_error(error_message);
251    if sanitized_message != error_message && !sanitized_message.is_empty() {
252        sanitized_message
253    } else {
254        error_message.to_string()
255    }
256}
257
258/// Format an API error from a status code and optional message
259pub fn format_api_error_from_status(status: Option<u16>, message: Option<&str>) -> String {
260    let msg = message.unwrap_or("");
261    let sanitized = sanitize_api_error(msg);
262
263    if !sanitized.is_empty() && sanitized != msg {
264        return sanitized;
265    }
266
267    if let Some(s) = status {
268        format!("API error (status {})", s)
269    } else {
270        "API error".to_string()
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn test_sanitize_message_html_plain() {
280        let result = sanitize_message_html("Plain error message");
281        assert_eq!(result, "Plain error message");
282    }
283
284    #[test]
285    fn test_sanitize_message_html_with_title() {
286        let html = "<!DOCTYPE html><html><title>Error Page</title></html>";
287        let result = sanitize_message_html(html);
288        assert_eq!(result, "Error Page");
289    }
290
291    #[test]
292    fn test_sanitize_message_html_cloudflare() {
293        let html = "<!DOCTYPE HTML><HTML><TITLE>Access Denied</TITLE></HTML>";
294        let result = sanitize_message_html(html);
295        assert_eq!(result, "Access Denied");
296    }
297
298    #[test]
299    fn test_extract_nested_error_message_standard() {
300        let json = serde_json::json!({
301            "error": {
302                "error": {
303                    "message": "test error message"
304                }
305            }
306        });
307        let result = extract_nested_error_message(&json);
308        assert_eq!(result, Some("test error message".to_string()));
309    }
310
311    #[test]
312    fn test_extract_nested_error_message_bedrock() {
313        let json = serde_json::json!({
314            "error": {
315                "message": "bedrock error"
316            }
317        });
318        let result = extract_nested_error_message(&json);
319        assert_eq!(result, Some("bedrock error".to_string()));
320    }
321
322    #[test]
323    fn test_format_api_error_timeout() {
324        let result = format_api_error("Connection timed out");
325        assert!(result.contains("timed out"));
326    }
327
328    #[test]
329    fn test_format_api_error_from_status() {
330        let result = format_api_error_from_status(Some(429), Some("Rate limited"));
331        assert!(result.contains("429"));
332    }
333
334    #[test]
335    fn test_extract_connection_error_details_from_message_timeout() {
336        let result = extract_connection_error_details_from_message("Connection timed out");
337        assert!(result.is_some());
338        let details = result.unwrap();
339        assert_eq!(details.code, "ETIMEDOUT");
340        assert!(!details.is_ssl_error);
341    }
342
343    #[test]
344    fn test_extract_connection_error_details_from_message_ssl() {
345        let result = extract_connection_error_details_from_message("SSL certificate error");
346        assert!(result.is_some());
347        let details = result.unwrap();
348        assert!(details.is_ssl_error);
349    }
350
351    #[test]
352    fn test_get_ssl_error_hint() {
353        let result = get_ssl_error_hint("SSL certificate error");
354        assert!(result.is_some());
355    }
356}