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 #[error("Local agent error: {0}")]
45 LocalAgent(String),
46}
47
48impl ClientError {
49 pub fn local_agent(msg: impl Into<String>) -> Self {
51 Self::LocalAgent(msg.into())
52 }
53}
54
55impl ClientError {
56 pub fn from_response(status: u16, body: &str) -> Self {
58 Self::from_response_with_url(status, body, None)
59 }
60
61 pub fn from_response_with_url(status: u16, body: &str, url: Option<&str>) -> Self {
63 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 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 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 pub fn is_retryable(&self) -> bool {
103 matches!(
104 self,
105 ClientError::RateLimited { .. } | ClientError::ServiceUnavailable(_)
106 )
107 }
108
109 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 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
187fn 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()); 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 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 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}