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 e.is_connect() {
135                    "Could not connect to the service endpoint.\n\
136                     Possible causes:\n\
137                     - The endpoint URL in hoist.toml may be incorrect (re-run 'hoist init' to rediscover)\n\
138                     - The service may be behind a private endpoint or VNet\n\
139                     - A firewall or DNS issue may be blocking the connection"
140                } else if e.is_timeout() {
141                    "The request timed out. The service may be unavailable or unreachable."
142                } else {
143                    "The HTTP request failed. Check network connectivity and the endpoint URL in hoist.toml."
144                }
145            }
146            ClientError::RateLimited { .. } => "Wait and retry the operation",
147            ClientError::ServiceUnavailable(_) => {
148                "The Azure Search service may be temporarily unavailable. Try again later."
149            }
150            _ => "Check the error message for details",
151        }
152    }
153
154    /// Get the raw response body (for error log details)
155    pub fn raw_body(&self) -> Option<&str> {
156        match self {
157            ClientError::Forbidden { body, .. } => Some(body),
158            ClientError::Api { message, .. } => Some(message),
159            ClientError::ServiceUnavailable(body) => Some(body),
160            _ => None,
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_from_response_azure_error_format() {
171        let body = r#"{"error": {"message": "Index not found", "code": "ResourceNotFound"}}"#;
172        let err = ClientError::from_response(404, body);
173        match err {
174            ClientError::Api { status, message } => {
175                assert_eq!(status, 404);
176                assert_eq!(message, "Index not found");
177            }
178            _ => panic!("Expected Api error"),
179        }
180    }
181
182    #[test]
183    fn test_from_response_plain_text() {
184        let body = "Something went wrong";
185        let err = ClientError::from_response(500, body);
186        match err {
187            ClientError::Api { status, message } => {
188                assert_eq!(status, 500);
189                assert_eq!(message, "Something went wrong");
190            }
191            _ => panic!("Expected Api error"),
192        }
193    }
194
195    #[test]
196    fn test_from_response_403_creates_forbidden() {
197        let body = r#"{"detail": "forbidden"}"#;
198        let err = ClientError::from_response(403, body);
199        match err {
200            ClientError::Forbidden {
201                service,
202                message,
203                body: raw,
204            } => {
205                assert_eq!(service, "unknown service");
206                assert!(message.is_empty()); // no error.message key in body
207                assert_eq!(raw, body);
208            }
209            _ => panic!("Expected Forbidden error, got {:?}", err),
210        }
211    }
212
213    #[test]
214    fn test_from_response_with_url_403_extracts_service() {
215        let body = r#"{"error": {"message": "Access denied"}}"#;
216        let err = ClientError::from_response_with_url(
217            403,
218            body,
219            Some("https://irma-prod-aisearch.search.windows.net/indexes?api-version=2024-07-01"),
220        );
221        match err {
222            ClientError::Forbidden {
223                service,
224                message,
225                body: _,
226            } => {
227                assert_eq!(service, "irma-prod-aisearch.search.windows.net");
228                assert_eq!(message, "Access denied");
229            }
230            _ => panic!("Expected Forbidden error, got {:?}", err),
231        }
232    }
233
234    #[test]
235    fn test_from_response_with_url_403_empty_body() {
236        let err = ClientError::from_response_with_url(
237            403,
238            "",
239            Some("https://my-svc.search.windows.net/indexes?api-version=2024-07-01"),
240        );
241        match err {
242            ClientError::Forbidden {
243                service,
244                message,
245                body,
246            } => {
247                assert_eq!(service, "my-svc.search.windows.net");
248                assert!(message.is_empty());
249                assert!(body.is_empty());
250            }
251            _ => panic!("Expected Forbidden error, got {:?}", err),
252        }
253    }
254
255    #[test]
256    fn test_from_response_empty_body_fallback() {
257        let err = ClientError::from_response(500, "  ");
258        match err {
259            ClientError::Api { status, message } => {
260                assert_eq!(status, 500);
261                assert!(message.contains("HTTP 500"));
262                assert!(message.contains("no error details"));
263            }
264            _ => panic!("Expected Api error"),
265        }
266    }
267
268    #[test]
269    fn test_suggestion_forbidden() {
270        let err = ClientError::Forbidden {
271            service: "my-svc.search.windows.net".to_string(),
272            message: "".to_string(),
273            body: "".to_string(),
274        };
275        let suggestion = err.suggestion();
276        assert!(suggestion.contains("RBAC is not enabled"));
277        assert!(suggestion.contains("Search Service Contributor"));
278        assert!(suggestion.contains("Search Index Data Contributor"));
279        assert!(suggestion.contains("aadOrApiKey"));
280        assert!(suggestion.contains("IP firewall"));
281    }
282
283    #[test]
284    fn test_raw_body_forbidden() {
285        let err = ClientError::Forbidden {
286            service: "svc".to_string(),
287            message: "".to_string(),
288            body: "raw error body".to_string(),
289        };
290        assert_eq!(err.raw_body(), Some("raw error body"));
291    }
292
293    #[test]
294    fn test_raw_body_api() {
295        let err = ClientError::Api {
296            status: 400,
297            message: "bad request".to_string(),
298        };
299        assert_eq!(err.raw_body(), Some("bad request"));
300    }
301
302    #[test]
303    fn test_raw_body_not_found_returns_none() {
304        let err = ClientError::NotFound {
305            kind: "Index".to_string(),
306            name: "x".to_string(),
307        };
308        assert_eq!(err.raw_body(), None);
309    }
310
311    #[test]
312    fn test_forbidden_display() {
313        let err = ClientError::Forbidden {
314            service: "my-svc.search.windows.net".to_string(),
315            message: "".to_string(),
316            body: "".to_string(),
317        };
318        let display = format!("{}", err);
319        assert!(display.contains("403 Forbidden"));
320        assert!(display.contains("my-svc.search.windows.net"));
321    }
322
323    #[test]
324    fn test_is_retryable_rate_limited() {
325        let err = ClientError::RateLimited { retry_after: 30 };
326        assert!(err.is_retryable());
327    }
328
329    #[test]
330    fn test_is_retryable_service_unavailable() {
331        let err = ClientError::ServiceUnavailable("down".to_string());
332        assert!(err.is_retryable());
333    }
334
335    #[test]
336    fn test_is_not_retryable_api_error() {
337        let err = ClientError::Api {
338            status: 400,
339            message: "bad request".to_string(),
340        };
341        assert!(!err.is_retryable());
342    }
343
344    #[test]
345    fn test_is_not_retryable_not_found() {
346        let err = ClientError::NotFound {
347            kind: "Index".to_string(),
348            name: "missing".to_string(),
349        };
350        assert!(!err.is_retryable());
351    }
352
353    #[test]
354    fn test_suggestion_not_logged_in() {
355        let err = ClientError::Auth(AuthError::NotLoggedIn);
356        assert!(err.suggestion().contains("az login"));
357    }
358
359    #[test]
360    fn test_suggestion_cli_not_found() {
361        let err = ClientError::Auth(AuthError::AzCliNotFound);
362        assert!(err.suggestion().contains("Install"));
363    }
364
365    #[test]
366    fn test_suggestion_not_found() {
367        let err = ClientError::NotFound {
368            kind: "Index".to_string(),
369            name: "x".to_string(),
370        };
371        assert!(err.suggestion().contains("Verify"));
372    }
373
374    #[test]
375    fn test_suggestion_rate_limited() {
376        let err = ClientError::RateLimited { retry_after: 60 };
377        assert!(err.suggestion().contains("retry"));
378    }
379}