Skip to main content

grr_cli/core/
error.rs

1//! Error types for grr-core
2
3use thiserror::Error;
4
5#[derive(Error, Debug)]
6pub enum GrrError {
7    #[error("Authentication error: {0}")]
8    Auth(Box<dyn std::error::Error + Send + Sync>),
9
10    #[error("HTTP request error: {0}")]
11    Http(#[from] reqwest::Error),
12
13    #[error("JSON serialization error: {0}")]
14    Json(#[from] serde_json::Error),
15
16    #[error("IO error: {0}")]
17    Io(#[from] std::io::Error),
18
19    #[error("URL parse error: {0}")]
20    Url(#[from] url::ParseError),
21
22    #[error("Configuration error: {0}")]
23    Config(String),
24
25    #[error("Rate limited: retry after {retry_after_secs}s")]
26    RateLimited { retry_after_secs: u64 },
27
28    #[error("API error {status}: {message}")]
29    Api { status: u16, message: String },
30
31    #[error("Not found: {0}")]
32    NotFound(String),
33
34    #[error("Permission denied: {0}")]
35    PermissionDenied(String),
36
37    #[error("Internal error: {0}")]
38    Internal(String),
39
40    #[error("Timeout: {0}")]
41    Timeout(String),
42
43    #[error(transparent)]
44    Anyhow(#[from] anyhow::Error),
45}
46
47impl GrrError {
48    /// Check if error is retryable
49    pub fn is_retryable(&self) -> bool {
50        match self {
51            GrrError::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
52            GrrError::RateLimited { .. } | GrrError::Timeout(_) => true,
53            GrrError::Api { status, .. } => *status >= 500 || *status == 429,
54            _ => false,
55        }
56    }
57
58    /// Get HTTP status code if available
59    pub fn status_code(&self) -> Option<u16> {
60        match self {
61            GrrError::Api { status, .. } => Some(*status),
62            GrrError::Http(e) => e.status().map(|s| s.as_u16()),
63            GrrError::RateLimited { .. } => Some(429),
64            _ => None,
65        }
66    }
67}
68
69pub type Result<T> = std::result::Result<T, GrrError>;
70
71pub(crate) fn api_error(status: u16, body: &str) -> GrrError {
72    let message = serde_json::from_str::<serde_json::Value>(body)
73        .ok()
74        .map(|value| json_error_detail(&value))
75        .filter(|message| message != "unknown error")
76        .unwrap_or_else(|| {
77            let body = body.trim();
78            if body.is_empty() {
79                "HTTP request failed".to_string()
80            } else {
81                body.to_string()
82            }
83        });
84    GrrError::Api { status, message }
85}
86
87pub(crate) fn json_error_detail(body: &serde_json::Value) -> String {
88    let reason = body
89        .get("error")
90        .and_then(|value| {
91            value
92                .as_str()
93                .or_else(|| value.get("message").and_then(serde_json::Value::as_str))
94        })
95        .or_else(|| body.get("message").and_then(serde_json::Value::as_str))
96        .unwrap_or("unknown error");
97    let description = body
98        .get("error_description")
99        .and_then(serde_json::Value::as_str)
100        .or_else(|| body.get("description").and_then(serde_json::Value::as_str))
101        .unwrap_or("");
102    if description.is_empty() || description == reason {
103        reason.to_string()
104    } else {
105        format!("{reason}: {description}")
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112    use serde_json::json;
113
114    #[test]
115    fn extracts_google_api_error_messages() {
116        let error = api_error(
117            400,
118            r#"{"error":{"code":400,"message":"invalid query","status":"INVALID_ARGUMENT"}}"#,
119        );
120        assert!(
121            matches!(error, GrrError::Api { status: 400, message } if message == "invalid query")
122        );
123    }
124
125    #[test]
126    fn extracts_oauth_error_details() {
127        let value = json!({"error":"invalid_grant", "error_description":"expired"});
128        assert_eq!(json_error_detail(&value), "invalid_grant: expired");
129    }
130}