Skip to main content

faucet_stream/
error.rs

1//! Error types for faucet-stream.
2
3use std::time::Duration;
4use thiserror::Error;
5
6/// All possible errors returned by faucet-stream.
7#[derive(Debug, Error)]
8pub enum FaucetError {
9    #[error("HTTP error: {0}")]
10    Http(#[from] reqwest::Error),
11
12    /// An HTTP response with a non-success status code.
13    ///
14    /// Contains the status code, URL, and (truncated) response body for
15    /// debugging.  Whether this error is retriable depends on the status code
16    /// — see [`FaucetError::is_retriable`].
17    #[error("HTTP {status} from {url}: {body}")]
18    HttpStatus {
19        status: u16,
20        url: String,
21        body: String,
22    },
23
24    #[error("JSON error: {0}")]
25    Json(#[from] serde_json::Error),
26
27    #[error("JSONPath error: {0}")]
28    JsonPath(String),
29
30    #[error("Auth error: {0}")]
31    Auth(String),
32
33    /// The server responded with HTTP 429 Too Many Requests.
34    /// The inner value is the duration to wait before retrying,
35    /// parsed from the `Retry-After` response header (default: 60 s).
36    #[error("Rate limited: retry after {0:?}")]
37    RateLimited(Duration),
38
39    /// A URL could not be constructed or parsed.
40    #[error("URL error: {0}")]
41    Url(String),
42
43    /// A record transform could not be compiled or applied (e.g. invalid regex).
44    #[error("Transform error: {0}")]
45    Transform(String),
46}
47
48impl FaucetError {
49    /// Whether this error is transient and the request should be retried.
50    ///
51    /// Retriable errors:
52    /// - Network / connection errors (`Http` from reqwest)
53    /// - Server errors (5xx status codes)
54    /// - Rate limiting (429 — handled separately with `Retry-After`)
55    ///
56    /// Non-retriable errors:
57    /// - Client errors (4xx except 429)
58    /// - JSON parse / JSONPath / auth / transform errors
59    pub fn is_retriable(&self) -> bool {
60        match self {
61            // reqwest errors: connection timeouts, DNS failures, etc. are retriable.
62            FaucetError::Http(e) => {
63                // If it's a status error that leaked through, check the code.
64                if let Some(status) = e.status() {
65                    status.is_server_error()
66                } else {
67                    // Connection errors, timeouts, etc.
68                    true
69                }
70            }
71            FaucetError::HttpStatus { status, .. } => *status >= 500,
72            FaucetError::RateLimited(_) => true,
73            _ => false,
74        }
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    #[test]
83    fn http_status_5xx_is_retriable() {
84        let err = FaucetError::HttpStatus {
85            status: 500,
86            url: "https://example.com".into(),
87            body: "Internal Server Error".into(),
88        };
89        assert!(err.is_retriable());
90
91        let err = FaucetError::HttpStatus {
92            status: 503,
93            url: "https://example.com".into(),
94            body: "".into(),
95        };
96        assert!(err.is_retriable());
97    }
98
99    #[test]
100    fn http_status_4xx_is_not_retriable() {
101        let err = FaucetError::HttpStatus {
102            status: 400,
103            url: "https://example.com".into(),
104            body: "Bad Request".into(),
105        };
106        assert!(!err.is_retriable());
107
108        let err = FaucetError::HttpStatus {
109            status: 404,
110            url: "https://example.com".into(),
111            body: "".into(),
112        };
113        assert!(!err.is_retriable());
114    }
115
116    #[test]
117    fn rate_limited_is_retriable() {
118        let err = FaucetError::RateLimited(Duration::from_secs(30));
119        assert!(err.is_retriable());
120    }
121
122    #[test]
123    fn json_error_is_not_retriable() {
124        let serde_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
125        let err = FaucetError::Json(serde_err);
126        assert!(!err.is_retriable());
127    }
128
129    #[test]
130    fn jsonpath_error_is_not_retriable() {
131        let err = FaucetError::JsonPath("bad path".into());
132        assert!(!err.is_retriable());
133    }
134
135    #[test]
136    fn auth_error_is_not_retriable() {
137        let err = FaucetError::Auth("invalid token".into());
138        assert!(!err.is_retriable());
139    }
140
141    #[test]
142    fn url_error_is_not_retriable() {
143        let err = FaucetError::Url("bad url".into());
144        assert!(!err.is_retriable());
145    }
146
147    #[test]
148    fn transform_error_is_not_retriable() {
149        let err = FaucetError::Transform("bad regex".into());
150        assert!(!err.is_retriable());
151    }
152
153    #[test]
154    fn http_status_display_includes_url_and_body() {
155        let err = FaucetError::HttpStatus {
156            status: 422,
157            url: "https://api.example.com/test".into(),
158            body: "Unprocessable Entity".into(),
159        };
160        let msg = err.to_string();
161        assert!(msg.contains("422"));
162        assert!(msg.contains("https://api.example.com/test"));
163        assert!(msg.contains("Unprocessable Entity"));
164    }
165}