1use thiserror::Error;
4
5use crate::auth::AuthError;
6
7#[derive(Debug, Error)]
9pub enum ClientError {
10 #[error("Authentication error: {0}")]
18 Auth(AuthError),
19
20 #[error("HTTP request failed")]
27 Request(#[source] reqwest::Error),
28
29 #[error("API error ({status}): {message}")]
30 Api { status: u16, message: String },
31
32 #[error("Access denied (403 Forbidden): {service}")]
33 Forbidden {
34 service: String,
35 message: String,
36 body: String,
37 },
38
39 #[error("Resource not found: {kind} '{name}'")]
40 NotFound { kind: String, name: String },
41
42 #[error("Resource already exists: {kind} '{name}'")]
43 AlreadyExists { kind: String, name: String },
44
45 #[error("Invalid response: {0}")]
46 InvalidResponse(String),
47
48 #[error("Rate limited, retry after {retry_after} seconds")]
49 RateLimited { retry_after: u64 },
50
51 #[error("Service unavailable: {0}")]
52 ServiceUnavailable(String),
53
54 #[error("JSON error: {0}")]
55 Json(serde_json::Error),
56
57 #[error("Local agent error: {0}")]
58 LocalAgent(String),
59}
60
61impl From<AuthError> for ClientError {
68 fn from(err: AuthError) -> Self {
69 ClientError::Auth(err)
70 }
71}
72
73impl From<reqwest::Error> for ClientError {
74 fn from(err: reqwest::Error) -> Self {
75 ClientError::Request(err)
76 }
77}
78
79impl From<serde_json::Error> for ClientError {
80 fn from(err: serde_json::Error) -> Self {
81 ClientError::Json(err)
82 }
83}
84
85impl ClientError {
86 pub fn local_agent(msg: impl Into<String>) -> Self {
88 Self::LocalAgent(msg.into())
89 }
90}
91
92impl ClientError {
93 pub fn from_response(status: u16, body: &str) -> Self {
95 Self::from_response_with_url(status, body, None)
96 }
97
98 pub fn from_response_with_url(status: u16, body: &str, url: Option<&str>) -> Self {
100 let parsed_message = serde_json::from_str::<serde_json::Value>(body)
102 .ok()
103 .and_then(|json| {
104 json.get("error")
105 .and_then(|e| e.get("message"))
106 .and_then(|m| m.as_str())
107 .map(String::from)
108 });
109
110 if status == 403 {
112 let service = url
113 .and_then(|u| u.strip_prefix("https://").and_then(|s| s.split('/').next()))
114 .unwrap_or("unknown service")
115 .to_string();
116 let message = parsed_message.unwrap_or_default();
117 return Self::Forbidden {
118 service,
119 message,
120 body: body.to_string(),
121 };
122 }
123
124 if let Some(message) = parsed_message {
125 return Self::Api { status, message };
126 }
127
128 let message = if body.trim().is_empty() {
130 format!("HTTP {} with no error details from the server", status)
131 } else {
132 body.to_string()
133 };
134
135 Self::Api { status, message }
136 }
137
138 pub fn is_retryable(&self) -> bool {
140 matches!(
141 self,
142 ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
143 )
144 }
145
146 pub fn suggestion(&self) -> &'static str {
148 match self {
149 ClientError::Auth(AuthError::NotLoggedIn) => {
150 "Run 'az login' to authenticate with Azure CLI"
151 }
152 ClientError::Auth(AuthError::AzCliNotFound) => {
153 "Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli"
154 }
155 ClientError::Auth(AuthError::MissingEnvVar(_)) => {
156 "Set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID environment variables"
157 }
158 ClientError::Forbidden { .. } => {
159 "Access denied. The three most common causes are:\n\n\
160 1. RBAC is not enabled on the data plane (most likely)\n\
161 \x20 Azure AI Search uses API keys by default. To use Entra ID\n\
162 \x20 authentication (which rigg uses), enable RBAC:\n\
163 \x20 Portal: Settings > Keys > select \"Both\" or \"Role-based access control\"\n\
164 \x20 CLI: az search service update --name <name> --resource-group <rg> --auth-options aadOrApiKey\n\n\
165 2. Missing RBAC role assignment\n\
166 \x20 Assign roles on the search service resource:\n\
167 \x20 az role assignment create --assignee <you> --role \"Search Service Contributor\" --scope <resource-id>\n\
168 \x20 az role assignment create --assignee <you> --role \"Search Index Data Contributor\" --scope <resource-id>\n\
169 \x20 Role assignments can take up to 10 minutes to propagate.\n\n\
170 3. IP firewall blocking your request\n\
171 \x20 If the service has network restrictions, add your IP under Networking > Firewalls.\n\n\
172 See: https://learn.microsoft.com/en-us/azure/search/search-security-enable-roles"
173 }
174 ClientError::NotFound { .. } => {
175 "Verify the resource name and that you have access to it"
176 }
177 ClientError::AlreadyExists { .. } => {
178 "Use a different name or delete the existing resource first"
179 }
180 ClientError::Request(e) => {
181 if has_certificate_error(e) {
182 "TLS certificate verification failed.\n\
183 The remote server's certificate was not trusted. This typically happens on\n\
184 corporate networks that use TLS inspection with a custom CA certificate.\n\n\
185 Fix: Install the corporate root CA certificate into your operating system's\n\
186 certificate store:\n\
187 macOS: Add to Keychain Access > System > Certificates\n\
188 Linux: Copy to /usr/local/share/ca-certificates/ and run update-ca-certificates\n\
189 Windows: Import via certmgr.msc > Trusted Root Certification Authorities"
190 } else if e.is_connect() {
191 "Could not connect to the service endpoint.\n\
192 Possible causes:\n\
193 - The endpoint URL in rigg.toml may be incorrect (re-run 'rigg init' to rediscover)\n\
194 - The service may be behind a private endpoint or VNet\n\
195 - A firewall or DNS issue may be blocking the connection"
196 } else if e.is_timeout() {
197 "The request timed out. The service may be unavailable or unreachable."
198 } else {
199 "The HTTP request failed. Check network connectivity and the endpoint URL in rigg.toml."
200 }
201 }
202 ClientError::RateLimited { .. } => "Wait and retry the operation",
203 ClientError::ServiceUnavailable(_) => {
204 "The Azure Search service may be temporarily unavailable. Try again later."
205 }
206 ClientError::LocalAgent(_) => {
207 "Check that the AI provider is installed and configured. Run 'rigg ai init' to reconfigure."
208 }
209 _ => "Check the error message for details",
210 }
211 }
212
213 pub fn raw_body(&self) -> Option<&str> {
215 match self {
216 ClientError::Forbidden { body, .. } => Some(body),
217 ClientError::Api { message, .. } => Some(message),
218 ClientError::ServiceUnavailable(body) => Some(body),
219 _ => None,
220 }
221 }
222}
223
224fn has_certificate_error(err: &reqwest::Error) -> bool {
226 use std::error::Error;
227 let mut source = err.source();
228 while let Some(cause) = source {
229 let msg = cause.to_string();
230 if msg.contains("certificate") || msg.contains("UnknownIssuer") {
231 return true;
232 }
233 source = cause.source();
234 }
235 false
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn test_from_response_azure_error_format() {
244 let body = r#"{"error": {"message": "Index not found", "code": "ResourceNotFound"}}"#;
245 let err = ClientError::from_response(404, body);
246 match err {
247 ClientError::Api { status, message } => {
248 assert_eq!(status, 404);
249 assert_eq!(message, "Index not found");
250 }
251 _ => panic!("Expected Api error"),
252 }
253 }
254
255 #[test]
256 fn test_from_response_plain_text() {
257 let body = "Something went wrong";
258 let err = ClientError::from_response(500, body);
259 match err {
260 ClientError::Api { status, message } => {
261 assert_eq!(status, 500);
262 assert_eq!(message, "Something went wrong");
263 }
264 _ => panic!("Expected Api error"),
265 }
266 }
267
268 #[test]
269 fn test_from_response_403_creates_forbidden() {
270 let body = r#"{"detail": "forbidden"}"#;
271 let err = ClientError::from_response(403, body);
272 match err {
273 ClientError::Forbidden {
274 service,
275 message,
276 body: raw,
277 } => {
278 assert_eq!(service, "unknown service");
279 assert!(message.is_empty()); assert_eq!(raw, body);
281 }
282 _ => panic!("Expected Forbidden error, got {:?}", err),
283 }
284 }
285
286 #[test]
287 fn test_from_response_with_url_403_extracts_service() {
288 let body = r#"{"error": {"message": "Access denied"}}"#;
289 let err = ClientError::from_response_with_url(
290 403,
291 body,
292 Some("https://irma-prod-aisearch.search.windows.net/indexes?api-version=2024-07-01"),
293 );
294 match err {
295 ClientError::Forbidden {
296 service,
297 message,
298 body: _,
299 } => {
300 assert_eq!(service, "irma-prod-aisearch.search.windows.net");
301 assert_eq!(message, "Access denied");
302 }
303 _ => panic!("Expected Forbidden error, got {:?}", err),
304 }
305 }
306
307 #[test]
308 fn test_from_response_with_url_403_empty_body() {
309 let err = ClientError::from_response_with_url(
310 403,
311 "",
312 Some("https://my-svc.search.windows.net/indexes?api-version=2024-07-01"),
313 );
314 match err {
315 ClientError::Forbidden {
316 service,
317 message,
318 body,
319 } => {
320 assert_eq!(service, "my-svc.search.windows.net");
321 assert!(message.is_empty());
322 assert!(body.is_empty());
323 }
324 _ => panic!("Expected Forbidden error, got {:?}", err),
325 }
326 }
327
328 #[test]
329 fn test_from_response_empty_body_fallback() {
330 let err = ClientError::from_response(500, " ");
331 match err {
332 ClientError::Api { status, message } => {
333 assert_eq!(status, 500);
334 assert!(message.contains("HTTP 500"));
335 assert!(message.contains("no error details"));
336 }
337 _ => panic!("Expected Api error"),
338 }
339 }
340
341 #[test]
342 fn test_suggestion_forbidden() {
343 let err = ClientError::Forbidden {
344 service: "my-svc.search.windows.net".to_string(),
345 message: "".to_string(),
346 body: "".to_string(),
347 };
348 let suggestion = err.suggestion();
349 assert!(suggestion.contains("RBAC is not enabled"));
350 assert!(suggestion.contains("Search Service Contributor"));
351 assert!(suggestion.contains("Search Index Data Contributor"));
352 assert!(suggestion.contains("aadOrApiKey"));
353 assert!(suggestion.contains("IP firewall"));
354 }
355
356 #[test]
357 fn test_raw_body_forbidden() {
358 let err = ClientError::Forbidden {
359 service: "svc".to_string(),
360 message: "".to_string(),
361 body: "raw error body".to_string(),
362 };
363 assert_eq!(err.raw_body(), Some("raw error body"));
364 }
365
366 #[test]
367 fn test_raw_body_api() {
368 let err = ClientError::Api {
369 status: 400,
370 message: "bad request".to_string(),
371 };
372 assert_eq!(err.raw_body(), Some("bad request"));
373 }
374
375 #[test]
376 fn test_raw_body_not_found_returns_none() {
377 let err = ClientError::NotFound {
378 kind: "Index".to_string(),
379 name: "x".to_string(),
380 };
381 assert_eq!(err.raw_body(), None);
382 }
383
384 #[test]
385 fn test_forbidden_display() {
386 let err = ClientError::Forbidden {
387 service: "my-svc.search.windows.net".to_string(),
388 message: "".to_string(),
389 body: "".to_string(),
390 };
391 let display = format!("{}", err);
392 assert!(display.contains("403 Forbidden"));
393 assert!(display.contains("my-svc.search.windows.net"));
394 }
395
396 #[test]
397 fn test_is_retryable_rate_limited() {
398 let err = ClientError::RateLimited { retry_after: 30 };
399 assert!(err.is_retryable());
400 }
401
402 #[test]
403 fn test_is_retryable_service_unavailable() {
404 let err = ClientError::ServiceUnavailable("down".to_string());
405 assert!(err.is_retryable());
406 }
407
408 #[test]
409 fn test_is_not_retryable_api_error() {
410 let err = ClientError::Api {
411 status: 400,
412 message: "bad request".to_string(),
413 };
414 assert!(!err.is_retryable());
415 }
416
417 #[test]
418 fn test_is_not_retryable_not_found() {
419 let err = ClientError::NotFound {
420 kind: "Index".to_string(),
421 name: "missing".to_string(),
422 };
423 assert!(!err.is_retryable());
424 }
425
426 #[test]
427 fn test_suggestion_not_logged_in() {
428 let err = ClientError::Auth(AuthError::NotLoggedIn);
429 assert!(err.suggestion().contains("az login"));
430 }
431
432 #[test]
433 fn test_suggestion_cli_not_found() {
434 let err = ClientError::Auth(AuthError::AzCliNotFound);
435 assert!(err.suggestion().contains("Install"));
436 }
437
438 #[test]
439 fn test_suggestion_not_found() {
440 let err = ClientError::NotFound {
441 kind: "Index".to_string(),
442 name: "x".to_string(),
443 };
444 assert!(err.suggestion().contains("Verify"));
445 }
446
447 #[test]
448 fn test_suggestion_rate_limited() {
449 let err = ClientError::RateLimited { retry_after: 60 };
450 assert!(err.suggestion().contains("retry"));
451 }
452
453 #[test]
454 fn test_has_certificate_error_with_cert_message() {
455 let check =
457 |msg: &str| -> bool { msg.contains("certificate") || msg.contains("UnknownIssuer") };
458 assert!(check("invalid peer certificate: UnknownIssuer"));
459 assert!(check("certificate verify failed"));
460 assert!(check("self signed certificate in certificate chain"));
461 assert!(!check("connection refused"));
462 assert!(!check("timeout"));
463 }
464
465 #[test]
466 fn test_suggestion_for_generic_request_error() {
467 let suggestion = "The HTTP request failed. Check network connectivity and the endpoint URL in rigg.toml.";
471 assert!(suggestion.contains("HTTP request failed"));
472 }
473
474 #[test]
475 fn auth_error_chain_renders_cause_exactly_once() {
476 let client_err = ClientError::from(AuthError::TokenError("boom".to_string()));
477 let chained = anyhow::Error::from(client_err).context("failed to list remote data-sources");
478 let rendered = format!("{chained:#}");
479 assert_eq!(
480 rendered.matches("boom").count(),
481 1,
482 "cause must appear exactly once: {rendered}"
483 );
484 assert!(rendered.contains("Authentication error"), "{rendered}");
485 assert!(
486 rendered.contains("failed to list remote data-sources"),
487 "{rendered}"
488 );
489 }
490
491 #[test]
492 fn request_error_chain_keeps_deep_causes_and_renders_each_once() {
493 let reqwest_err = reqwest::Client::new()
497 .get("not-a-valid-url")
498 .build()
499 .unwrap_err();
500 let reqwest_display = reqwest_err.to_string();
501 let client_err = ClientError::from(reqwest_err);
502 let chained = anyhow::Error::from(client_err).context("failed to list remote indexes");
503 let rendered = format!("{chained:#}");
504 assert_eq!(
505 rendered.matches("HTTP request failed").count(),
506 1,
507 "prefix must appear exactly once: {rendered}"
508 );
509 assert_eq!(
510 rendered.matches(reqwest_display.as_str()).count(),
511 1,
512 "reqwest display must appear exactly once: {rendered}"
513 );
514 assert!(
516 rendered.contains("relative URL without a base"),
517 "deep cause must not be dropped: {rendered}"
518 );
519 }
520}