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 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 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
174fn 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()); 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 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 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}