Skip to main content

hoist_client/
error.rs

1//! Client error types
2
3use thiserror::Error;
4
5use crate::auth::AuthError;
6
7/// Azure Search client errors
8#[derive(Debug, Error)]
9pub enum ClientError {
10    #[error("Authentication error: {0}")]
11    Auth(#[from] AuthError),
12
13    #[error("HTTP request failed: {0}")]
14    Request(#[from] reqwest::Error),
15
16    #[error("API error ({status}): {message}")]
17    Api { status: u16, message: String },
18
19    #[error("Access denied (403 Forbidden): {service}")]
20    Forbidden {
21        service: String,
22        message: String,
23        body: String,
24    },
25
26    #[error("Resource not found: {kind} '{name}'")]
27    NotFound { kind: String, name: String },
28
29    #[error("Resource already exists: {kind} '{name}'")]
30    AlreadyExists { kind: String, name: String },
31
32    #[error("Invalid response: {0}")]
33    InvalidResponse(String),
34
35    #[error("Rate limited, retry after {retry_after} seconds")]
36    RateLimited { retry_after: u64 },
37
38    #[error("Service unavailable: {0}")]
39    ServiceUnavailable(String),
40
41    #[error("JSON error: {0}")]
42    Json(#[from] serde_json::Error),
43}
44
45impl ClientError {
46    /// Create an API error from HTTP status, response body, and request URL
47    pub fn from_response(status: u16, body: &str) -> Self {
48        Self::from_response_with_url(status, body, None)
49    }
50
51    /// Create an API error with the originating URL for richer diagnostics
52    pub fn from_response_with_url(status: u16, body: &str, url: Option<&str>) -> Self {
53        // Extract message from Azure error format
54        let parsed_message = serde_json::from_str::<serde_json::Value>(body)
55            .ok()
56            .and_then(|json| {
57                json.get("error")
58                    .and_then(|e| e.get("message"))
59                    .and_then(|m| m.as_str())
60                    .map(String::from)
61            });
62
63        // For 403, create a Forbidden error with actionable context
64        if status == 403 {
65            let service = url
66                .and_then(|u| u.strip_prefix("https://").and_then(|s| s.split('/').next()))
67                .unwrap_or("unknown service")
68                .to_string();
69            let message = parsed_message.unwrap_or_default();
70            return Self::Forbidden {
71                service,
72                message,
73                body: body.to_string(),
74            };
75        }
76
77        if let Some(message) = parsed_message {
78            return Self::Api { status, message };
79        }
80
81        // Provide a human-readable fallback when the body is empty
82        let message = if body.trim().is_empty() {
83            format!("HTTP {} with no error details from the server", status)
84        } else {
85            body.to_string()
86        };
87
88        Self::Api { status, message }
89    }
90
91    /// Check if this error is retryable
92    pub fn is_retryable(&self) -> bool {
93        matches!(
94            self,
95            ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
96        )
97    }
98
99    /// Get suggested action for this error
100    pub fn suggestion(&self) -> &'static str {
101        match self {
102            ClientError::Auth(AuthError::NotLoggedIn) => {
103                "Run 'az login' to authenticate with Azure CLI"
104            }
105            ClientError::Auth(AuthError::AzCliNotFound) => {
106                "Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli"
107            }
108            ClientError::Auth(AuthError::MissingEnvVar(_)) => {
109                "Set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID environment variables"
110            }
111            ClientError::Forbidden { .. } => {
112                "Access denied. The three most common causes are:\n\n\
113                 1. RBAC is not enabled on the data plane (most likely)\n\
114                 \x20  Azure AI Search uses API keys by default. To use Entra ID\n\
115                 \x20  authentication (which hoist uses), enable RBAC:\n\
116                 \x20  Portal: Settings > Keys > select \"Both\" or \"Role-based access control\"\n\
117                 \x20  CLI:    az search service update --name <name> --resource-group <rg> --auth-options aadOrApiKey\n\n\
118                 2. Missing RBAC role assignment\n\
119                 \x20  Assign roles on the search service resource:\n\
120                 \x20  az role assignment create --assignee <you> --role \"Search Service Contributor\" --scope <resource-id>\n\
121                 \x20  az role assignment create --assignee <you> --role \"Search Index Data Contributor\" --scope <resource-id>\n\
122                 \x20  Role assignments can take up to 10 minutes to propagate.\n\n\
123                 3. IP firewall blocking your request\n\
124                 \x20  If the service has network restrictions, add your IP under Networking > Firewalls.\n\n\
125                 See: https://learn.microsoft.com/en-us/azure/search/search-security-enable-roles"
126            }
127            ClientError::NotFound { .. } => {
128                "Verify the resource name and that you have access to it"
129            }
130            ClientError::AlreadyExists { .. } => {
131                "Use a different name or delete the existing resource first"
132            }
133            ClientError::Request(e) => {
134                if has_certificate_error(e) {
135                    "TLS certificate verification failed.\n\
136                     The remote server's certificate was not trusted. This typically happens on\n\
137                     corporate networks that use TLS inspection with a custom CA certificate.\n\n\
138                     Fix: Install the corporate root CA certificate into your operating system's\n\
139                     certificate store:\n\
140                       macOS:   Add to Keychain Access > System > Certificates\n\
141                       Linux:   Copy to /usr/local/share/ca-certificates/ and run update-ca-certificates\n\
142                       Windows: Import via certmgr.msc > Trusted Root Certification Authorities"
143                } else if e.is_connect() {
144                    "Could not connect to the service endpoint.\n\
145                     Possible causes:\n\
146                     - The endpoint URL in hoist.toml may be incorrect (re-run 'hoist init' to rediscover)\n\
147                     - The service may be behind a private endpoint or VNet\n\
148                     - A firewall or DNS issue may be blocking the connection"
149                } else if e.is_timeout() {
150                    "The request timed out. The service may be unavailable or unreachable."
151                } else {
152                    "The HTTP request failed. Check network connectivity and the endpoint URL in hoist.toml."
153                }
154            }
155            ClientError::RateLimited { .. } => "Wait and retry the operation",
156            ClientError::ServiceUnavailable(_) => {
157                "The Azure Search service may be temporarily unavailable. Try again later."
158            }
159            _ => "Check the error message for details",
160        }
161    }
162
163    /// Get the raw response body (for error log details)
164    pub fn raw_body(&self) -> Option<&str> {
165        match self {
166            ClientError::Forbidden { body, .. } => Some(body),
167            ClientError::Api { message, .. } => Some(message),
168            ClientError::ServiceUnavailable(body) => Some(body),
169            _ => None,
170        }
171    }
172}
173
174/// Check if a reqwest error is caused by a TLS certificate verification failure
175fn has_certificate_error(err: &reqwest::Error) -> bool {
176    use std::error::Error;
177    let mut source = err.source();
178    while let Some(cause) = source {
179        let msg = cause.to_string();
180        if msg.contains("certificate") || msg.contains("UnknownIssuer") {
181            return true;
182        }
183        source = cause.source();
184    }
185    false
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_from_response_azure_error_format() {
194        let body = r#"{"error": {"message": "Index not found", "code": "ResourceNotFound"}}"#;
195        let err = ClientError::from_response(404, body);
196        match err {
197            ClientError::Api { status, message } => {
198                assert_eq!(status, 404);
199                assert_eq!(message, "Index not found");
200            }
201            _ => panic!("Expected Api error"),
202        }
203    }
204
205    #[test]
206    fn test_from_response_plain_text() {
207        let body = "Something went wrong";
208        let err = ClientError::from_response(500, body);
209        match err {
210            ClientError::Api { status, message } => {
211                assert_eq!(status, 500);
212                assert_eq!(message, "Something went wrong");
213            }
214            _ => panic!("Expected Api error"),
215        }
216    }
217
218    #[test]
219    fn test_from_response_403_creates_forbidden() {
220        let body = r#"{"detail": "forbidden"}"#;
221        let err = ClientError::from_response(403, body);
222        match err {
223            ClientError::Forbidden {
224                service,
225                message,
226                body: raw,
227            } => {
228                assert_eq!(service, "unknown service");
229                assert!(message.is_empty()); // no error.message key in body
230                assert_eq!(raw, body);
231            }
232            _ => panic!("Expected Forbidden error, got {:?}", err),
233        }
234    }
235
236    #[test]
237    fn test_from_response_with_url_403_extracts_service() {
238        let body = r#"{"error": {"message": "Access denied"}}"#;
239        let err = ClientError::from_response_with_url(
240            403,
241            body,
242            Some("https://irma-prod-aisearch.search.windows.net/indexes?api-version=2024-07-01"),
243        );
244        match err {
245            ClientError::Forbidden {
246                service,
247                message,
248                body: _,
249            } => {
250                assert_eq!(service, "irma-prod-aisearch.search.windows.net");
251                assert_eq!(message, "Access denied");
252            }
253            _ => panic!("Expected Forbidden error, got {:?}", err),
254        }
255    }
256
257    #[test]
258    fn test_from_response_with_url_403_empty_body() {
259        let err = ClientError::from_response_with_url(
260            403,
261            "",
262            Some("https://my-svc.search.windows.net/indexes?api-version=2024-07-01"),
263        );
264        match err {
265            ClientError::Forbidden {
266                service,
267                message,
268                body,
269            } => {
270                assert_eq!(service, "my-svc.search.windows.net");
271                assert!(message.is_empty());
272                assert!(body.is_empty());
273            }
274            _ => panic!("Expected Forbidden error, got {:?}", err),
275        }
276    }
277
278    #[test]
279    fn test_from_response_empty_body_fallback() {
280        let err = ClientError::from_response(500, "  ");
281        match err {
282            ClientError::Api { status, message } => {
283                assert_eq!(status, 500);
284                assert!(message.contains("HTTP 500"));
285                assert!(message.contains("no error details"));
286            }
287            _ => panic!("Expected Api error"),
288        }
289    }
290
291    #[test]
292    fn test_suggestion_forbidden() {
293        let err = ClientError::Forbidden {
294            service: "my-svc.search.windows.net".to_string(),
295            message: "".to_string(),
296            body: "".to_string(),
297        };
298        let suggestion = err.suggestion();
299        assert!(suggestion.contains("RBAC is not enabled"));
300        assert!(suggestion.contains("Search Service Contributor"));
301        assert!(suggestion.contains("Search Index Data Contributor"));
302        assert!(suggestion.contains("aadOrApiKey"));
303        assert!(suggestion.contains("IP firewall"));
304    }
305
306    #[test]
307    fn test_raw_body_forbidden() {
308        let err = ClientError::Forbidden {
309            service: "svc".to_string(),
310            message: "".to_string(),
311            body: "raw error body".to_string(),
312        };
313        assert_eq!(err.raw_body(), Some("raw error body"));
314    }
315
316    #[test]
317    fn test_raw_body_api() {
318        let err = ClientError::Api {
319            status: 400,
320            message: "bad request".to_string(),
321        };
322        assert_eq!(err.raw_body(), Some("bad request"));
323    }
324
325    #[test]
326    fn test_raw_body_not_found_returns_none() {
327        let err = ClientError::NotFound {
328            kind: "Index".to_string(),
329            name: "x".to_string(),
330        };
331        assert_eq!(err.raw_body(), None);
332    }
333
334    #[test]
335    fn test_forbidden_display() {
336        let err = ClientError::Forbidden {
337            service: "my-svc.search.windows.net".to_string(),
338            message: "".to_string(),
339            body: "".to_string(),
340        };
341        let display = format!("{}", err);
342        assert!(display.contains("403 Forbidden"));
343        assert!(display.contains("my-svc.search.windows.net"));
344    }
345
346    #[test]
347    fn test_is_retryable_rate_limited() {
348        let err = ClientError::RateLimited { retry_after: 30 };
349        assert!(err.is_retryable());
350    }
351
352    #[test]
353    fn test_is_retryable_service_unavailable() {
354        let err = ClientError::ServiceUnavailable("down".to_string());
355        assert!(err.is_retryable());
356    }
357
358    #[test]
359    fn test_is_not_retryable_api_error() {
360        let err = ClientError::Api {
361            status: 400,
362            message: "bad request".to_string(),
363        };
364        assert!(!err.is_retryable());
365    }
366
367    #[test]
368    fn test_is_not_retryable_not_found() {
369        let err = ClientError::NotFound {
370            kind: "Index".to_string(),
371            name: "missing".to_string(),
372        };
373        assert!(!err.is_retryable());
374    }
375
376    #[test]
377    fn test_suggestion_not_logged_in() {
378        let err = ClientError::Auth(AuthError::NotLoggedIn);
379        assert!(err.suggestion().contains("az login"));
380    }
381
382    #[test]
383    fn test_suggestion_cli_not_found() {
384        let err = ClientError::Auth(AuthError::AzCliNotFound);
385        assert!(err.suggestion().contains("Install"));
386    }
387
388    #[test]
389    fn test_suggestion_not_found() {
390        let err = ClientError::NotFound {
391            kind: "Index".to_string(),
392            name: "x".to_string(),
393        };
394        assert!(err.suggestion().contains("Verify"));
395    }
396
397    #[test]
398    fn test_suggestion_rate_limited() {
399        let err = ClientError::RateLimited { retry_after: 60 };
400        assert!(err.suggestion().contains("retry"));
401    }
402
403    #[test]
404    fn test_has_certificate_error_with_cert_message() {
405        // Test the helper function directly with string matching logic
406        let check =
407            |msg: &str| -> bool { msg.contains("certificate") || msg.contains("UnknownIssuer") };
408        assert!(check("invalid peer certificate: UnknownIssuer"));
409        assert!(check("certificate verify failed"));
410        assert!(check("self signed certificate in certificate chain"));
411        assert!(!check("connection refused"));
412        assert!(!check("timeout"));
413    }
414
415    #[test]
416    fn test_suggestion_for_generic_request_error() {
417        // Verify that non-cert request errors still get the generic message
418        // We can't easily construct a reqwest::Error, but we can verify
419        // the suggestion arm logic: if not cert, not connect, not timeout → generic
420        let suggestion = "The HTTP request failed. Check network connectivity and the endpoint URL in hoist.toml.";
421        assert!(suggestion.contains("HTTP request failed"));
422    }
423}