saluki_io/net/util/retry/classifier/
http.rs

1use std::sync::Arc;
2
3use http::Response;
4
5use super::RetryClassifier;
6
7/// A predicate that decides whether a response should be treated as retriable.
8///
9/// The predicate receives the response and returns `true` if the response should be retried.
10pub type HttpRetryPredicate<B = ()> = Arc<dyn Fn(&Response<B>) -> bool + Send + Sync>;
11
12fn default_should_retry<B>(response: &Response<B>) -> bool {
13    let status = response.status();
14
15    match status {
16        // There are some status codes that likely indicate a fundamental misconfiguration or bug on the client side
17        // which won't be resolved by retrying the request.
18        http::StatusCode::BAD_REQUEST
19        | http::StatusCode::UNAUTHORIZED
20        | http::StatusCode::FORBIDDEN
21        | http::StatusCode::PAYLOAD_TOO_LARGE => {
22            // These statuses are permanent failures — the transaction is dropped, not retried (a data-loss path).
23            // Anchor that the run reaches it.
24            saluki_antithesis::sometimes!(
25                true,
26                "transaction permanently dropped — non-retryable status",
27                { "status": status.as_u16() }
28            );
29            false
30        }
31
32        // For all other status codes, we'll only retry if they're in the client/server error range.
33        _ => status.is_client_error() || status.is_server_error(),
34    }
35}
36
37/// A standard HTTP response classifier.
38///
39/// Generally treats all client (4xx) and server (5xx) errors as retriable, with the exception of a few specific client
40/// errors that shouldn't be retried:
41///
42/// - 400 Bad Request (likely a client-side bug)
43/// - 401 Unauthorized (likely a client-side misconfiguration)
44/// - 403 Forbidden (likely a client-side misconfiguration)
45/// - 413 Payload Too Large (likely a client-side bug)
46///
47/// Additional [`HttpRetryPredicate`]s can be registered via [`StandardHttpClassifier::with_predicate`]. A response is
48/// retried if any predicate—including the default—returns `true` (OR semantics). This allows callers to
49/// selectively unlock retries for status codes that the default predicate would not retry, without affecting other
50/// status codes.
51pub struct StandardHttpClassifier<B = ()> {
52    predicates: Vec<HttpRetryPredicate<B>>,
53}
54
55impl<B> Clone for StandardHttpClassifier<B> {
56    fn clone(&self) -> Self {
57        Self {
58            predicates: self.predicates.clone(),
59        }
60    }
61}
62
63impl<B: 'static> Default for StandardHttpClassifier<B> {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl<B: 'static> StandardHttpClassifier<B> {
70    /// Creates a new [`StandardHttpClassifier`] with the default status-code predicate installed.
71    pub fn new() -> Self {
72        Self {
73            predicates: vec![Arc::new(default_should_retry::<B>)],
74        }
75    }
76
77    /// Adds a predicate.
78    ///
79    /// A response is retried if any predicate—including the default—returns `true` (OR semantics).
80    pub fn with_predicate(mut self, predicate: HttpRetryPredicate<B>) -> Self {
81        self.predicates.push(predicate);
82        self
83    }
84}
85
86impl<B, Error> RetryClassifier<http::Response<B>, Error> for StandardHttpClassifier<B> {
87    fn should_retry(&self, response: &Result<http::Response<B>, Error>) -> bool {
88        match response {
89            Ok(resp) => self.predicates.iter().any(|p| p(resp)),
90            Err(_) => true,
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use std::sync::atomic::{AtomicBool, Ordering};
98
99    use http::StatusCode;
100
101    use super::*;
102
103    type TestResponse = Result<http::Response<()>, ()>;
104
105    fn ok(status: StatusCode) -> TestResponse {
106        Ok(http::Response::builder().status(status).body(()).unwrap())
107    }
108
109    fn err() -> TestResponse {
110        Err(())
111    }
112
113    fn classify(classifier: &StandardHttpClassifier<()>, response: &TestResponse) -> bool {
114        <StandardHttpClassifier<()> as RetryClassifier<http::Response<()>, ()>>::should_retry(classifier, response)
115    }
116
117    #[test]
118    fn default_classifier_retries_5xx_and_most_4xx() {
119        let classifier = StandardHttpClassifier::new();
120
121        assert!(!classify(&classifier, &ok(StatusCode::OK)));
122        assert!(!classify(&classifier, &ok(StatusCode::NO_CONTENT)));
123
124        for status in [
125            StatusCode::INTERNAL_SERVER_ERROR,
126            StatusCode::BAD_GATEWAY,
127            StatusCode::SERVICE_UNAVAILABLE,
128            StatusCode::GATEWAY_TIMEOUT,
129        ] {
130            assert!(classify(&classifier, &ok(status)), "{} should be retried", status);
131        }
132
133        for status in [
134            StatusCode::REQUEST_TIMEOUT,
135            StatusCode::TOO_MANY_REQUESTS,
136            StatusCode::NOT_FOUND,
137        ] {
138            assert!(classify(&classifier, &ok(status)), "{} should be retried", status);
139        }
140    }
141
142    #[test]
143    fn default_classifier_does_not_retry_known_client_misconfig() {
144        let classifier = StandardHttpClassifier::new();
145
146        for status in [
147            StatusCode::BAD_REQUEST,
148            StatusCode::UNAUTHORIZED,
149            StatusCode::FORBIDDEN,
150            StatusCode::PAYLOAD_TOO_LARGE,
151        ] {
152            assert!(!classify(&classifier, &ok(status)), "{} should not be retried", status);
153        }
154    }
155
156    #[test]
157    fn default_classifier_retries_transport_error() {
158        let classifier = StandardHttpClassifier::new();
159        assert!(classify(&classifier, &err()));
160    }
161
162    #[test]
163    fn predicate_adds_retry_for_403() {
164        let classifier = StandardHttpClassifier::new()
165            .with_predicate(Arc::new(|response| response.status() == StatusCode::FORBIDDEN));
166
167        assert!(classify(&classifier, &ok(StatusCode::FORBIDDEN)));
168        // Sibling client-misconfig statuses without a matching predicate keep their default (non-retriable) behavior.
169        assert!(!classify(&classifier, &ok(StatusCode::UNAUTHORIZED)));
170        assert!(!classify(&classifier, &ok(StatusCode::BAD_REQUEST)));
171        // Status codes that are retried by default are unaffected.
172        assert!(classify(&classifier, &ok(StatusCode::INTERNAL_SERVER_ERROR)));
173    }
174
175    #[test]
176    fn predicate_is_re_evaluated_each_call() {
177        let flag = Arc::new(AtomicBool::new(false));
178        let flag_clone = Arc::clone(&flag);
179        let predicate: HttpRetryPredicate =
180            Arc::new(move |response| response.status() == StatusCode::FORBIDDEN && flag_clone.load(Ordering::SeqCst));
181
182        let classifier = StandardHttpClassifier::new().with_predicate(predicate);
183
184        assert!(!classify(&classifier, &ok(StatusCode::FORBIDDEN)));
185
186        flag.store(true, Ordering::SeqCst);
187        assert!(classify(&classifier, &ok(StatusCode::FORBIDDEN)));
188
189        flag.store(false, Ordering::SeqCst);
190        assert!(!classify(&classifier, &ok(StatusCode::FORBIDDEN)));
191    }
192}