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("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 pub fn from_response(status: u16, body: &str) -> Self {
48 Self::from_response_with_url(status, body, None)
49 }
50
51 pub fn from_response_with_url(status: u16, body: &str, url: Option<&str>) -> Self {
53 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 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 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 pub fn is_retryable(&self) -> bool {
93 matches!(
94 self,
95 ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
96 )
97 }
98
99 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 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()); 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}