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    #[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    #[error("Local agent error: {0}")]
45    LocalAgent(String),
46}
47
48impl ClientError {
49    /// Create a local agent error
50    pub fn local_agent(msg: impl Into<String>) -> Self {
51        Self::LocalAgent(msg.into())
52    }
53}
54
55impl ClientError {
56    /// Create an API error from HTTP status, response body, and request URL
57    pub fn from_response(status: u16, body: &str) -> Self {
58        Self::from_response_with_url(status, body, None)
59    }
60
61    /// Create an API error with the originating URL for richer diagnostics
62    pub fn from_response_with_url(status: u16, body: &str, url: Option<&str>) -> Self {
63        // Extract message from Azure error format
64        let parsed_message = serde_json::from_str::<serde_json::Value>(body)
65            .ok()
66            .and_then(|json| {
67                json.get("error")
68                    .and_then(|e| e.get("message"))
69                    .and_then(|m| m.as_str())
70                    .map(String::from)
71            });
72
73        // For 403, create a Forbidden error with actionable context
74        if status == 403 {
75            let service = url
76                .and_then(|u| u.strip_prefix("https://").and_then(|s| s.split('/').next()))
77                .unwrap_or("unknown service")
78                .to_string();
79            let message = parsed_message.unwrap_or_default();
80            return Self::Forbidden {
81                service,
82                message,
83                body: body.to_string(),
84            };
85        }
86
87        if let Some(message) = parsed_message {
88            return Self::Api { status, message };
89        }
90
91        // Provide a human-readable fallback when the body is empty
92        let message = if body.trim().is_empty() {
93            format!("HTTP {} with no error details from the server", status)
94        } else {
95            body.to_string()
96        };
97
98        Self::Api { status, message }
99    }
100
101    /// Check if this error is retryable
102    pub fn is_retryable(&self) -> bool {
103        matches!(
104            self,
105            ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
106        )
107    }
108
109    /// Get suggested action for this error
110    pub fn suggestion(&self) -> &'static str {
111        match self {
112            ClientError::Auth(AuthError::NotLoggedIn) => {
113                "Run 'az login' to authenticate with Azure CLI"
114            }
115            ClientError::Auth(AuthError::AzCliNotFound) => {
116                "Install Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli"
117            }
118            ClientError::Auth(AuthError::MissingEnvVar(_)) => {
119                "Set AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, and AZURE_TENANT_ID environment variables"
120            }
121            ClientError::Forbidden { .. } => {
122                "Access denied. The three most common causes are:\n\n\
123                 1. RBAC is not enabled on the data plane (most likely)\n\
124                 \x20  Azure AI Search uses API keys by default. To use Entra ID\n\
125                 \x20  authentication (which rigg uses), enable RBAC:\n\
126                 \x20  Portal: Settings > Keys > select \"Both\" or \"Role-based access control\"\n\
127                 \x20  CLI:    az search service update --name <name> --resource-group <rg> --auth-options aadOrApiKey\n\n\
128                 2. Missing RBAC role assignment\n\
129                 \x20  Assign roles on the search service resource:\n\
130                 \x20  az role assignment create --assignee <you> --role \"Search Service Contributor\" --scope <resource-id>\n\
131                 \x20  az role assignment create --assignee <you> --role \"Search Index Data Contributor\" --scope <resource-id>\n\
132                 \x20  Role assignments can take up to 10 minutes to propagate.\n\n\
133                 3. IP firewall blocking your request\n\
134                 \x20  If the service has network restrictions, add your IP under Networking > Firewalls.\n\n\
135                 See: https://learn.microsoft.com/en-us/azure/search/search-security-enable-roles"
136            }
137            ClientError::NotFound { .. } => {
138                "Verify the resource name and that you have access to it"
139            }
140            ClientError::AlreadyExists { .. } => {
141                "Use a different name or delete the existing resource first"
142            }
143            ClientError::Request(e) => {
144                if has_certificate_error(e) {
145                    "TLS certificate verification failed.\n\
146                     The remote server's certificate was not trusted. This typically happens on\n\
147                     corporate networks that use TLS inspection with a custom CA certificate.\n\n\
148                     Fix: Install the corporate root CA certificate into your operating system's\n\
149                     certificate store:\n\
150                       macOS:   Add to Keychain Access > System > Certificates\n\
151                       Linux:   Copy to /usr/local/share/ca-certificates/ and run update-ca-certificates\n\
152                       Windows: Import via certmgr.msc > Trusted Root Certification Authorities"
153                } else if e.is_connect() {
154                    "Could not connect to the service endpoint.\n\
155                     Possible causes:\n\
156                     - The endpoint URL in rigg.toml may be incorrect (re-run 'rigg init' to rediscover)\n\
157                     - The service may be behind a private endpoint or VNet\n\
158                     - A firewall or DNS issue may be blocking the connection"
159                } else if e.is_timeout() {
160                    "The request timed out. The service may be unavailable or unreachable."
161                } else {
162                    "The HTTP request failed. Check network connectivity and the endpoint URL in rigg.toml."
163                }
164            }
165            ClientError::RateLimited { .. } => "Wait and retry the operation",
166            ClientError::ServiceUnavailable(_) => {
167                "The Azure Search service may be temporarily unavailable. Try again later."
168            }
169            ClientError::LocalAgent(_) => {
170                "Check that the AI provider is installed and configured. Run 'rigg ai init' to reconfigure."
171            }
172            _ => "Check the error message for details",
173        }
174    }
175
176    /// Get the raw response body (for error log details)
177    pub fn raw_body(&self) -> Option<&str> {
178        match self {
179            ClientError::Forbidden { body, .. } => Some(body),
180            ClientError::Api { message, .. } => Some(message),
181            ClientError::ServiceUnavailable(body) => Some(body),
182            _ => None,
183        }
184    }
185}
186
187/// Check if a reqwest error is caused by a TLS certificate verification failure
188fn has_certificate_error(err: &reqwest::Error) -> bool {
189    use std::error::Error;
190    let mut source = err.source();
191    while let Some(cause) = source {
192        let msg = cause.to_string();
193        if msg.contains("certificate") || msg.contains("UnknownIssuer") {
194            return true;
195        }
196        source = cause.source();
197    }
198    false
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_from_response_azure_error_format() {
207        let body = r#"{"error": {"message": "Index not found", "code": "ResourceNotFound"}}"#;
208        let err = ClientError::from_response(404, body);
209        match err {
210            ClientError::Api { status, message } => {
211                assert_eq!(status, 404);
212                assert_eq!(message, "Index not found");
213            }
214            _ => panic!("Expected Api error"),
215        }
216    }
217
218    #[test]
219    fn test_from_response_plain_text() {
220        let body = "Something went wrong";
221        let err = ClientError::from_response(500, body);
222        match err {
223            ClientError::Api { status, message } => {
224                assert_eq!(status, 500);
225                assert_eq!(message, "Something went wrong");
226            }
227            _ => panic!("Expected Api error"),
228        }
229    }
230
231    #[test]
232    fn test_from_response_403_creates_forbidden() {
233        let body = r#"{"detail": "forbidden"}"#;
234        let err = ClientError::from_response(403, body);
235        match err {
236            ClientError::Forbidden {
237                service,
238                message,
239                body: raw,
240            } => {
241                assert_eq!(service, "unknown service");
242                assert!(message.is_empty()); // no error.message key in body
243                assert_eq!(raw, body);
244            }
245            _ => panic!("Expected Forbidden error, got {:?}", err),
246        }
247    }
248
249    #[test]
250    fn test_from_response_with_url_403_extracts_service() {
251        let body = r#"{"error": {"message": "Access denied"}}"#;
252        let err = ClientError::from_response_with_url(
253            403,
254            body,
255            Some("https://irma-prod-aisearch.search.windows.net/indexes?api-version=2024-07-01"),
256        );
257        match err {
258            ClientError::Forbidden {
259                service,
260                message,
261                body: _,
262            } => {
263                assert_eq!(service, "irma-prod-aisearch.search.windows.net");
264                assert_eq!(message, "Access denied");
265            }
266            _ => panic!("Expected Forbidden error, got {:?}", err),
267        }
268    }
269
270    #[test]
271    fn test_from_response_with_url_403_empty_body() {
272        let err = ClientError::from_response_with_url(
273            403,
274            "",
275            Some("https://my-svc.search.windows.net/indexes?api-version=2024-07-01"),
276        );
277        match err {
278            ClientError::Forbidden {
279                service,
280                message,
281                body,
282            } => {
283                assert_eq!(service, "my-svc.search.windows.net");
284                assert!(message.is_empty());
285                assert!(body.is_empty());
286            }
287            _ => panic!("Expected Forbidden error, got {:?}", err),
288        }
289    }
290
291    #[test]
292    fn test_from_response_empty_body_fallback() {
293        let err = ClientError::from_response(500, "  ");
294        match err {
295            ClientError::Api { status, message } => {
296                assert_eq!(status, 500);
297                assert!(message.contains("HTTP 500"));
298                assert!(message.contains("no error details"));
299            }
300            _ => panic!("Expected Api error"),
301        }
302    }
303
304    #[test]
305    fn test_suggestion_forbidden() {
306        let err = ClientError::Forbidden {
307            service: "my-svc.search.windows.net".to_string(),
308            message: "".to_string(),
309            body: "".to_string(),
310        };
311        let suggestion = err.suggestion();
312        assert!(suggestion.contains("RBAC is not enabled"));
313        assert!(suggestion.contains("Search Service Contributor"));
314        assert!(suggestion.contains("Search Index Data Contributor"));
315        assert!(suggestion.contains("aadOrApiKey"));
316        assert!(suggestion.contains("IP firewall"));
317    }
318
319    #[test]
320    fn test_raw_body_forbidden() {
321        let err = ClientError::Forbidden {
322            service: "svc".to_string(),
323            message: "".to_string(),
324            body: "raw error body".to_string(),
325        };
326        assert_eq!(err.raw_body(), Some("raw error body"));
327    }
328
329    #[test]
330    fn test_raw_body_api() {
331        let err = ClientError::Api {
332            status: 400,
333            message: "bad request".to_string(),
334        };
335        assert_eq!(err.raw_body(), Some("bad request"));
336    }
337
338    #[test]
339    fn test_raw_body_not_found_returns_none() {
340        let err = ClientError::NotFound {
341            kind: "Index".to_string(),
342            name: "x".to_string(),
343        };
344        assert_eq!(err.raw_body(), None);
345    }
346
347    #[test]
348    fn test_forbidden_display() {
349        let err = ClientError::Forbidden {
350            service: "my-svc.search.windows.net".to_string(),
351            message: "".to_string(),
352            body: "".to_string(),
353        };
354        let display = format!("{}", err);
355        assert!(display.contains("403 Forbidden"));
356        assert!(display.contains("my-svc.search.windows.net"));
357    }
358
359    #[test]
360    fn test_is_retryable_rate_limited() {
361        let err = ClientError::RateLimited { retry_after: 30 };
362        assert!(err.is_retryable());
363    }
364
365    #[test]
366    fn test_is_retryable_service_unavailable() {
367        let err = ClientError::ServiceUnavailable("down".to_string());
368        assert!(err.is_retryable());
369    }
370
371    #[test]
372    fn test_is_not_retryable_api_error() {
373        let err = ClientError::Api {
374            status: 400,
375            message: "bad request".to_string(),
376        };
377        assert!(!err.is_retryable());
378    }
379
380    #[test]
381    fn test_is_not_retryable_not_found() {
382        let err = ClientError::NotFound {
383            kind: "Index".to_string(),
384            name: "missing".to_string(),
385        };
386        assert!(!err.is_retryable());
387    }
388
389    #[test]
390    fn test_suggestion_not_logged_in() {
391        let err = ClientError::Auth(AuthError::NotLoggedIn);
392        assert!(err.suggestion().contains("az login"));
393    }
394
395    #[test]
396    fn test_suggestion_cli_not_found() {
397        let err = ClientError::Auth(AuthError::AzCliNotFound);
398        assert!(err.suggestion().contains("Install"));
399    }
400
401    #[test]
402    fn test_suggestion_not_found() {
403        let err = ClientError::NotFound {
404            kind: "Index".to_string(),
405            name: "x".to_string(),
406        };
407        assert!(err.suggestion().contains("Verify"));
408    }
409
410    #[test]
411    fn test_suggestion_rate_limited() {
412        let err = ClientError::RateLimited { retry_after: 60 };
413        assert!(err.suggestion().contains("retry"));
414    }
415
416    #[test]
417    fn test_has_certificate_error_with_cert_message() {
418        // Test the helper function directly with string matching logic
419        let check =
420            |msg: &str| -> bool { msg.contains("certificate") || msg.contains("UnknownIssuer") };
421        assert!(check("invalid peer certificate: UnknownIssuer"));
422        assert!(check("certificate verify failed"));
423        assert!(check("self signed certificate in certificate chain"));
424        assert!(!check("connection refused"));
425        assert!(!check("timeout"));
426    }
427
428    #[test]
429    fn test_suggestion_for_generic_request_error() {
430        // Verify that non-cert request errors still get the generic message
431        // We can't easily construct a reqwest::Error, but we can verify
432        // the suggestion arm logic: if not cert, not connect, not timeout → generic
433        let suggestion = "The HTTP request failed. Check network connectivity and the endpoint URL in rigg.toml.";
434        assert!(suggestion.contains("HTTP request failed"));
435    }
436}