Skip to main content

faucet_core/resilience/
classify.rs

1//! Classify a [`FaucetError`] into a retry class, and a small set of classes
2//! the user opts into via `retry_on: [...]`.
3
4use crate::error::FaucetError;
5
6/// The transient-failure class an error belongs to. Closed set so it can be a
7/// bounded metric label and a `retry_on` config value.
8#[derive(
9    Debug,
10    Clone,
11    Copy,
12    PartialEq,
13    Eq,
14    Hash,
15    serde::Serialize,
16    serde::Deserialize,
17    schemars::JsonSchema,
18)]
19#[serde(rename_all = "snake_case")]
20pub enum RetryClass {
21    /// HTTP 5xx server error.
22    #[serde(rename = "http_5xx")]
23    Http5xx,
24    /// HTTP 429 / rate-limit signal.
25    RateLimited,
26    /// Connection-level failure (DNS, refused, reset) with no HTTP status.
27    Connection,
28    /// Request timeout.
29    Timeout,
30}
31
32impl RetryClass {
33    /// Stable Prometheus label value.
34    pub fn as_str(self) -> &'static str {
35        match self {
36            RetryClass::Http5xx => "http_5xx",
37            RetryClass::RateLimited => "rate_limited",
38            RetryClass::Connection => "connection",
39            RetryClass::Timeout => "timeout",
40        }
41    }
42}
43
44/// The set of classes the user has opted into retrying. Backed by a fixed-size
45/// bool array (only four variants), so it is `Copy` and allocation-free.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct RetryClassSet([bool; 4]);
48
49impl RetryClassSet {
50    fn idx(c: RetryClass) -> usize {
51        match c {
52            RetryClass::Http5xx => 0,
53            RetryClass::RateLimited => 1,
54            RetryClass::Connection => 2,
55            RetryClass::Timeout => 3,
56        }
57    }
58
59    /// True when `c` is in the set.
60    pub fn contains(&self, c: RetryClass) -> bool {
61        self.0[Self::idx(c)]
62    }
63}
64
65impl Default for RetryClassSet {
66    /// All four classes — reproduces today's `FaucetError::is_retriable`.
67    fn default() -> Self {
68        RetryClassSet([true; 4])
69    }
70}
71
72impl FromIterator<RetryClass> for RetryClassSet {
73    fn from_iter<I: IntoIterator<Item = RetryClass>>(iter: I) -> Self {
74        let mut set = RetryClassSet([false; 4]);
75        for c in iter {
76            set.0[Self::idx(c)] = true;
77        }
78        set
79    }
80}
81
82/// Map a [`FaucetError`] to its [`RetryClass`], or `None` if it is not a
83/// transient failure. The single source of truth for retriability — both the
84/// policy runner and `FaucetError::is_retriable` defer to this.
85pub fn classify(err: &FaucetError) -> Option<RetryClass> {
86    match err {
87        FaucetError::HttpStatus { status, .. } if *status == 429 => Some(RetryClass::RateLimited),
88        FaucetError::HttpStatus { status, .. } if *status >= 500 => Some(RetryClass::Http5xx),
89        FaucetError::RateLimited(_) => Some(RetryClass::RateLimited),
90        FaucetError::Timeout { .. } => Some(RetryClass::Timeout),
91        FaucetError::Http(e) => classify_transport(
92            e.is_timeout(),
93            e.status().map(|s| s.as_u16()),
94            e.is_connect(),
95        ),
96        _ => None,
97    }
98}
99
100/// Pure classification of a reqwest transport error from its discriminating
101/// predicates. Factored out of the [`FaucetError::Http`] arm so it is unit-testable
102/// without constructing a `reqwest::Error` (which has no public constructor).
103///
104/// A timeout wins regardless of any attached status. A 5xx/429 status maps to the
105/// matching class; any other status is non-transient (`None`). With no status, a
106/// connect failure and every other transport error (body/decode mid-stream) are
107/// treated as connection-class transient — so `is_connect` does not change the
108/// outcome and is accepted only to mirror the reqwest predicate at the call site.
109/// This is a strict superset of the legacy `FaucetError::is_retriable`, so behavior
110/// is preserved.
111fn classify_transport(
112    is_timeout: bool,
113    status: Option<u16>,
114    _is_connect: bool,
115) -> Option<RetryClass> {
116    if is_timeout {
117        Some(RetryClass::Timeout)
118    } else if let Some(status) = status {
119        if status >= 500 {
120            Some(RetryClass::Http5xx)
121        } else if status == 429 {
122            Some(RetryClass::RateLimited)
123        } else {
124            None
125        }
126    } else {
127        // Statusless, non-timeout transport error: a connect failure and every
128        // other transport error (body/decode mid-stream) are connection-class.
129        Some(RetryClass::Connection)
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use std::time::Duration;
137
138    #[test]
139    fn classifies_5xx_429_and_rate_limited() {
140        assert_eq!(
141            classify(&FaucetError::HttpStatus {
142                status: 503,
143                url: "u".into(),
144                body: "".into()
145            }),
146            Some(RetryClass::Http5xx)
147        );
148        assert_eq!(
149            classify(&FaucetError::HttpStatus {
150                status: 429,
151                url: "u".into(),
152                body: "".into()
153            }),
154            Some(RetryClass::RateLimited)
155        );
156        assert_eq!(
157            classify(&FaucetError::RateLimited(Duration::from_secs(1))),
158            Some(RetryClass::RateLimited)
159        );
160    }
161
162    #[test]
163    fn a_typed_timeout_is_the_timeout_class() {
164        assert_eq!(
165            classify(&FaucetError::Timeout {
166                operation: "rabbitmq publish".into(),
167                after: Duration::from_secs(60),
168            }),
169            Some(RetryClass::Timeout)
170        );
171    }
172
173    #[test]
174    fn transport_classification_covers_every_arm() {
175        // Timeout wins regardless of status.
176        assert_eq!(
177            classify_transport(true, None, false),
178            Some(RetryClass::Timeout)
179        );
180        assert_eq!(
181            classify_transport(true, Some(500), true),
182            Some(RetryClass::Timeout)
183        );
184        // Status-bearing transport errors.
185        assert_eq!(
186            classify_transport(false, Some(503), false),
187            Some(RetryClass::Http5xx)
188        );
189        assert_eq!(
190            classify_transport(false, Some(429), false),
191            Some(RetryClass::RateLimited)
192        );
193        // A non-5xx/429 status is not transient.
194        assert_eq!(classify_transport(false, Some(404), false), None);
195        // Statusless: connect and other transport errors are connection-class.
196        assert_eq!(
197            classify_transport(false, None, true),
198            Some(RetryClass::Connection)
199        );
200        assert_eq!(
201            classify_transport(false, None, false),
202            Some(RetryClass::Connection)
203        );
204    }
205
206    #[test]
207    fn non_retriable_errors_classify_none() {
208        assert_eq!(classify(&FaucetError::Auth("x".into())), None);
209        assert_eq!(classify(&FaucetError::Config("x".into())), None);
210        assert_eq!(classify(&FaucetError::Sink("x".into())), None);
211    }
212
213    #[test]
214    fn retry_class_serde_round_trips() {
215        use serde_json::json;
216        // Http5xx serializes/deserializes as `http_5xx`, matching the metric
217        // label and the docs/example config value.
218        assert_eq!(
219            serde_json::from_value::<RetryClass>(json!("http_5xx")).unwrap(),
220            RetryClass::Http5xx
221        );
222        assert_eq!(
223            serde_json::to_value(RetryClass::Http5xx).unwrap(),
224            json!("http_5xx")
225        );
226        // The other three round-trip via their snake_case names.
227        for (s, c) in [
228            ("rate_limited", RetryClass::RateLimited),
229            ("connection", RetryClass::Connection),
230            ("timeout", RetryClass::Timeout),
231        ] {
232            assert_eq!(serde_json::from_value::<RetryClass>(json!(s)).unwrap(), c);
233            assert_eq!(serde_json::to_value(c).unwrap(), json!(s));
234        }
235    }
236
237    #[test]
238    fn set_contains_and_default_covers_all_four() {
239        let set = RetryClassSet::default();
240        for c in [
241            RetryClass::Http5xx,
242            RetryClass::RateLimited,
243            RetryClass::Connection,
244            RetryClass::Timeout,
245        ] {
246            assert!(set.contains(c), "default set should contain {c:?}");
247        }
248        let only5xx = RetryClassSet::from_iter([RetryClass::Http5xx]);
249        assert!(only5xx.contains(RetryClass::Http5xx));
250        assert!(!only5xx.contains(RetryClass::RateLimited));
251    }
252}