ai_agent/services/api/
error_utils.rs1use std::collections::HashSet;
6
7static SSL_ERROR_CODES: Lazy<HashSet<&'static str>> = Lazy::new(|| {
9 let mut set = HashSet::new();
10 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 set.insert("DEPTH_ZERO_SELF_SIGNED_CERT");
22 set.insert("SELF_SIGNED_CERT_IN_CHAIN");
23 set.insert("CERT_CHAIN_TOO_LONG");
25 set.insert("PATH_LENGTH_EXCEEDED");
26 set.insert("ERR_TLS_CERT_ALTNAME_INVALID");
28 set.insert("HOSTNAME_MISMATCH");
29 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#[derive(Debug, Clone)]
40pub struct ConnectionErrorDetails {
41 pub code: String,
42 pub message: String,
43 pub is_ssl_error: bool,
44}
45
46pub fn extract_connection_error_details_from_message(msg: &str) -> Option<ConnectionErrorDetails> {
48 let lower = msg.to_lowercase();
50
51 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 let is_ssl = lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate");
62 if is_ssl {
63 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 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 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
102pub 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
114fn sanitize_message_html(message: &str) -> String {
116 let lower = message.to_lowercase();
117 if lower.contains("<!DOCTYPE html") || lower.contains("<html") {
118 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
132pub 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#[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
169pub fn extract_nested_error_message(error: &serde_json::Value) -> Option<String> {
171 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 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
192pub fn format_api_error(error_message: &str) -> String {
194 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 if code == "ETIMEDOUT" {
202 return "Request timed out. Check your internet connection and proxy settings"
203 .to_string();
204 }
205
206 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
258pub 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}