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("Resource not found: {kind} '{name}'")]
20    NotFound { kind: String, name: String },
21
22    #[error("Resource already exists: {kind} '{name}'")]
23    AlreadyExists { kind: String, name: String },
24
25    #[error("Invalid response: {0}")]
26    InvalidResponse(String),
27
28    #[error("Rate limited, retry after {retry_after} seconds")]
29    RateLimited { retry_after: u64 },
30
31    #[error("Service unavailable: {0}")]
32    ServiceUnavailable(String),
33
34    #[error("JSON error: {0}")]
35    Json(#[from] serde_json::Error),
36}
37
38impl ClientError {
39    /// Create an API error from HTTP status and response body
40    pub fn from_response(status: u16, body: &str) -> Self {
41        // Try to parse Azure error format
42        if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
43            if let Some(error) = json.get("error") {
44                let message = error
45                    .get("message")
46                    .and_then(|m| m.as_str())
47                    .unwrap_or(body)
48                    .to_string();
49                return Self::Api { status, message };
50            }
51        }
52
53        Self::Api {
54            status,
55            message: body.to_string(),
56        }
57    }
58
59    /// Check if this error is retryable
60    pub fn is_retryable(&self) -> bool {
61        matches!(
62            self,
63            ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
64        )
65    }
66
67    /// Get suggested action for this error
68    pub fn suggestion(&self) -> &'static str {
69        match self {
70            ClientError::Auth(AuthError::NotLoggedIn) => {
71                "Run 'az login' to authenticate with Azure CLI"
72            }
73            ClientError::Auth(AuthError::AzCliNotFound) => {
74                "Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli"
75            }
76            ClientError::Auth(AuthError::MissingEnvVar(_)) => {
77                "Set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID environment variables"
78            }
79            ClientError::NotFound { .. } => {
80                "Verify the resource name and that you have access to it"
81            }
82            ClientError::AlreadyExists { .. } => {
83                "Use a different name or delete the existing resource first"
84            }
85            ClientError::RateLimited { .. } => "Wait and retry the operation",
86            ClientError::ServiceUnavailable(_) => {
87                "The Azure Search service may be temporarily unavailable. Try again later."
88            }
89            _ => "Check the error message for details",
90        }
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_from_response_azure_error_format() {
100        let body = r#"{"error": {"message": "Index not found", "code": "ResourceNotFound"}}"#;
101        let err = ClientError::from_response(404, body);
102        match err {
103            ClientError::Api { status, message } => {
104                assert_eq!(status, 404);
105                assert_eq!(message, "Index not found");
106            }
107            _ => panic!("Expected Api error"),
108        }
109    }
110
111    #[test]
112    fn test_from_response_plain_text() {
113        let body = "Something went wrong";
114        let err = ClientError::from_response(500, body);
115        match err {
116            ClientError::Api { status, message } => {
117                assert_eq!(status, 500);
118                assert_eq!(message, "Something went wrong");
119            }
120            _ => panic!("Expected Api error"),
121        }
122    }
123
124    #[test]
125    fn test_from_response_json_without_error_key() {
126        let body = r#"{"detail": "forbidden"}"#;
127        let err = ClientError::from_response(403, body);
128        match err {
129            ClientError::Api { status, message } => {
130                assert_eq!(status, 403);
131                assert_eq!(message, body);
132            }
133            _ => panic!("Expected Api error"),
134        }
135    }
136
137    #[test]
138    fn test_is_retryable_rate_limited() {
139        let err = ClientError::RateLimited { retry_after: 30 };
140        assert!(err.is_retryable());
141    }
142
143    #[test]
144    fn test_is_retryable_service_unavailable() {
145        let err = ClientError::ServiceUnavailable("down".to_string());
146        assert!(err.is_retryable());
147    }
148
149    #[test]
150    fn test_is_not_retryable_api_error() {
151        let err = ClientError::Api {
152            status: 400,
153            message: "bad request".to_string(),
154        };
155        assert!(!err.is_retryable());
156    }
157
158    #[test]
159    fn test_is_not_retryable_not_found() {
160        let err = ClientError::NotFound {
161            kind: "Index".to_string(),
162            name: "missing".to_string(),
163        };
164        assert!(!err.is_retryable());
165    }
166
167    #[test]
168    fn test_suggestion_not_logged_in() {
169        let err = ClientError::Auth(AuthError::NotLoggedIn);
170        assert!(err.suggestion().contains("az login"));
171    }
172
173    #[test]
174    fn test_suggestion_cli_not_found() {
175        let err = ClientError::Auth(AuthError::AzCliNotFound);
176        assert!(err.suggestion().contains("Install"));
177    }
178
179    #[test]
180    fn test_suggestion_not_found() {
181        let err = ClientError::NotFound {
182            kind: "Index".to_string(),
183            name: "x".to_string(),
184        };
185        assert!(err.suggestion().contains("Verify"));
186    }
187
188    #[test]
189    fn test_suggestion_rate_limited() {
190        let err = ClientError::RateLimited { retry_after: 60 };
191        assert!(err.suggestion().contains("retry"));
192    }
193}