1use thiserror::Error;
4
5use crate::auth::AuthError;
6
7#[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 pub fn from_response(status: u16, body: &str) -> Self {
41 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 pub fn is_retryable(&self) -> bool {
61 matches!(
62 self,
63 ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
64 )
65 }
66
67 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}