Skip to main content

rigg_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    // NOTE: `{0}` embeds the cause's Display in this variant's own message,
11    // so we deliberately do NOT also mark it `#[from]` (which would make
12    // thiserror implement `source()` to return the same value). Doing both
13    // renders the cause twice in an anyhow chain (`{:#}` walks both the
14    // Display text and the `source()` chain). Conversion via `?` is instead
15    // provided by the manual `impl From<...>` below, which is functionally
16    // identical to `#[from]` except it does not wire up `source()`.
17    #[error("Authentication error: {0}")]
18    Auth(AuthError),
19
20    // Unlike Auth/Json, reqwest errors carry deeper causes in their own
21    // source chain (TLS `UnknownIssuer`, connect-refused detail, ...), so
22    // this variant keeps a `#[source]` (without `#[from]`) and does NOT
23    // embed `{0}` in its message. anyhow's `{:#}` then renders
24    // `HTTP request failed: <reqwest display>: <deeper causes>` — prefix
25    // plus the full chain, each segment exactly once.
26    #[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
61// Manual `From` impls replacing `#[from]` for the variants above — see the
62// comments on `ClientError::Auth` and `ClientError::Request` for why. These
63// preserve `?`-based conversion exactly as `#[from]` would, while letting
64// each variant choose independently whether the cause lives in its Display
65// (`Auth`, `Json`) or in `source()` (`Request`) — never both, which is what
66// duplicated the cause when rendering anyhow chains.
67impl 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    /// Create a local agent error
87    pub fn local_agent(msg: impl Into<String>) -> Self {
88        Self::LocalAgent(msg.into())
89    }
90}
91
92impl ClientError {
93    /// Create an API error from HTTP status, response body, and request URL
94    pub fn from_response(status: u16, body: &str) -> Self {
95        Self::from_response_with_url(status, body, None)
96    }
97
98    /// Create an API error with the originating URL for richer diagnostics
99    pub fn from_response_with_url(status: u16, body: &str, url: Option<&str>) -> Self {
100        // Extract message from Azure error format
101        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        // For 403, create a Forbidden error with actionable context
111        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        // Provide a human-readable fallback when the body is empty
129        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    /// Check if this error is retryable
139    pub fn is_retryable(&self) -> bool {
140        matches!(
141            self,
142            ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
143        )
144    }
145
146    /// Get suggested action for this error
147    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    /// Get the raw response body (for error log details)
214    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
224/// Check if a reqwest error is caused by a TLS certificate verification failure
225fn 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()); // no error.message key in body
280                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        // Test the helper function directly with string matching logic
456        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        // Verify that non-cert request errors still get the generic message
468        // We can't easily construct a reqwest::Error, but we can verify
469        // the suggestion arm logic: if not cert, not connect, not timeout → generic
470        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        // A cheaply-constructible reqwest error with a real deeper cause:
494        // building a request from an unparseable URL yields a "builder error"
495        // whose source() is the underlying url::ParseError.
496        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        // The deeper cause (url::ParseError) must still surface via source().
515        assert!(
516            rendered.contains("relative URL without a base"),
517            "deep cause must not be dropped: {rendered}"
518        );
519    }
520}