Skip to main content

infrahub/
error.rs

1//! error types
2//!
3//! structured errors for config, http, json, and graphql responses.
4
5use crate::graphql::GraphQlError;
6use std::fmt;
7
8/// graphql response status codes considered transient and worth retrying
9const RETRYABLE_STATUSES: &[u16] = &[429, 500, 502, 503, 504];
10
11/// library result type
12pub type Result<T> = std::result::Result<T, Error>;
13
14/// error type for client and codegen helpers
15#[derive(Debug, thiserror::Error)]
16pub enum Error {
17    #[error("config error: {0}")]
18    Config(String),
19
20    #[error("http error: {0}")]
21    Http(#[from] reqwest::Error),
22
23    #[error("url error: {0}")]
24    Url(#[from] url::ParseError),
25
26    #[error("json error: {0}")]
27    Json(#[from] serde_json::Error),
28
29    #[error("graphql error: {message}")]
30    GraphQl {
31        /// http status if available
32        status: Option<u16>,
33        /// graphql error list
34        errors: Vec<GraphQlError>,
35        /// raw response body
36        body: String,
37        /// top-level message
38        message: String,
39    },
40}
41
42impl Error {
43    /// true if the error looks like an auth failure
44    pub fn is_auth_error(&self) -> bool {
45        matches!(
46            self,
47            Error::GraphQl {
48                status: Some(401 | 403),
49                ..
50            }
51        ) || matches!(self, Error::Http(err) if err.status() == Some(reqwest::StatusCode::UNAUTHORIZED))
52    }
53
54    /// true if the error is transient and the request may succeed on retry
55    ///
56    /// config, url, and parse errors are permanent. http errors retry on
57    /// any 5xx, 429, and network-level failures; graphql errors retry only
58    /// on 429, 500, 502, 503, and 504.
59    pub fn is_retryable(&self) -> bool {
60        match self {
61            Error::Config(_) | Error::Url(_) | Error::Json(_) => false,
62            Error::Http(err) => {
63                if err.is_timeout() || err.is_connect() {
64                    return true;
65                }
66                match err.status() {
67                    Some(status) => {
68                        status.is_server_error() || status == reqwest::StatusCode::TOO_MANY_REQUESTS
69                    }
70                    // no status usually means a network-level failure
71                    None => true,
72                }
73            }
74            Error::GraphQl { status, .. } => {
75                status.is_some_and(|s| RETRYABLE_STATUSES.contains(&s))
76            }
77        }
78    }
79}
80
81impl fmt::Display for GraphQlError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(f, "{}", self.message)
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn test_is_auth_error() {
93        let err = Error::GraphQl {
94            status: Some(401),
95            errors: vec![],
96            body: String::new(),
97            message: "unauthorized".to_string(),
98        };
99        assert!(err.is_auth_error());
100
101        let err = Error::GraphQl {
102            status: Some(403),
103            errors: vec![],
104            body: String::new(),
105            message: "forbidden".to_string(),
106        };
107        assert!(err.is_auth_error());
108
109        let err = Error::GraphQl {
110            status: Some(500),
111            errors: vec![],
112            body: String::new(),
113            message: "server error".to_string(),
114        };
115        assert!(!err.is_auth_error());
116    }
117
118    #[test]
119    fn test_non_graphql_errors_not_auth() {
120        let config_err = Error::Config("bad".into());
121        assert!(!config_err.is_auth_error());
122
123        let url_err: Error = url::Url::parse(":::").unwrap_err().into();
124        assert!(!url_err.is_auth_error());
125
126        let json_err: Error = serde_json::from_str::<serde_json::Value>("!!!")
127            .unwrap_err()
128            .into();
129        assert!(!json_err.is_auth_error());
130    }
131
132    #[test]
133    fn test_is_retryable_server_errors() {
134        for status in [429, 500, 502, 503, 504] {
135            let err = Error::GraphQl {
136                status: Some(status),
137                errors: vec![],
138                body: String::new(),
139                message: "server error".to_string(),
140            };
141            assert!(err.is_retryable(), "status {status} should be retryable");
142        }
143    }
144
145    #[test]
146    fn test_is_not_retryable_client_errors() {
147        for status in [400, 401, 403, 404, 422] {
148            let err = Error::GraphQl {
149                status: Some(status),
150                errors: vec![],
151                body: String::new(),
152                message: "client error".to_string(),
153            };
154            assert!(
155                !err.is_retryable(),
156                "status {status} should not be retryable"
157            );
158        }
159    }
160
161    #[test]
162    fn test_is_not_retryable_config_url_json() {
163        let config_err = Error::Config("bad".into());
164        assert!(!config_err.is_retryable());
165
166        let url_err: Error = url::Url::parse(":::").unwrap_err().into();
167        assert!(!url_err.is_retryable());
168
169        let json_err: Error = serde_json::from_str::<serde_json::Value>("!!!")
170            .unwrap_err()
171            .into();
172        assert!(!json_err.is_retryable());
173    }
174}