Skip to main content

scatter_proxy/
classifier.rs

1use http::{HeaderMap, StatusCode};
2
3/// Three-level verdict returned by a [`BodyClassifier`] for every HTTP response.
4///
5/// The scheduler uses this to decide what to do next:
6/// - `Success` — task is done, proxy gets a positive health mark.
7/// - `ProxyBlocked` — this proxy failed for the target; counts as a proxy failure.
8/// - `TargetError` — the target itself is unhealthy; does **not** penalise the proxy
9///   but contributes toward tripping the host circuit breaker.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum BodyVerdict {
12    Success,
13    ProxyBlocked,
14    TargetError,
15}
16
17/// Trait that inspects a completed HTTP response and returns a [`BodyVerdict`].
18///
19/// Implement this to customise how your target site's responses are interpreted.
20pub trait BodyClassifier: Send + Sync + 'static {
21    fn classify(&self, status: StatusCode, headers: &HeaderMap, body: &[u8]) -> BodyVerdict;
22}
23
24/// Built-in classifier that covers the common case:
25///
26/// | Condition | Verdict |
27/// |-----------|---------|
28/// | 2xx **and** non-empty body | `Success` |
29/// | 2xx **and** empty body | `ProxyBlocked` |
30/// | 403 or 429 | `ProxyBlocked` |
31/// | 5xx | `TargetError` |
32/// | everything else | `ProxyBlocked` |
33pub struct DefaultClassifier;
34
35impl BodyClassifier for DefaultClassifier {
36    fn classify(&self, status: StatusCode, _headers: &HeaderMap, body: &[u8]) -> BodyVerdict {
37        if status.is_success() {
38            if body.is_empty() {
39                BodyVerdict::ProxyBlocked
40            } else {
41                BodyVerdict::Success
42            }
43        } else if status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS {
44            BodyVerdict::ProxyBlocked
45        } else if status.is_server_error() {
46            BodyVerdict::TargetError
47        } else {
48            BodyVerdict::ProxyBlocked
49        }
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    /// Helper that classifies with the default classifier, empty headers, and the
58    /// supplied status + body.
59    fn classify(status: u16, body: &[u8]) -> BodyVerdict {
60        let classifier = DefaultClassifier;
61        let headers = HeaderMap::new();
62        classifier.classify(StatusCode::from_u16(status).unwrap(), &headers, body)
63    }
64
65    // ── 2xx ──────────────────────────────────────────────────────────────
66
67    #[test]
68    fn success_200_with_body() {
69        assert_eq!(classify(200, b"hello"), BodyVerdict::Success);
70    }
71
72    #[test]
73    fn success_201_with_body() {
74        assert_eq!(classify(201, b"{\"id\":1}"), BodyVerdict::Success);
75    }
76
77    #[test]
78    fn success_204_no_content_empty_body() {
79        // 204 is 2xx but body is empty → ProxyBlocked per the default rules.
80        assert_eq!(classify(204, b""), BodyVerdict::ProxyBlocked);
81    }
82
83    #[test]
84    fn proxy_blocked_200_empty_body() {
85        assert_eq!(classify(200, b""), BodyVerdict::ProxyBlocked);
86    }
87
88    // ── 403 / 429 ───────────────────────────────────────────────────────
89
90    #[test]
91    fn proxy_blocked_403() {
92        assert_eq!(classify(403, b"Forbidden"), BodyVerdict::ProxyBlocked);
93    }
94
95    #[test]
96    fn proxy_blocked_429() {
97        assert_eq!(classify(429, b"Rate limited"), BodyVerdict::ProxyBlocked);
98    }
99
100    // ── 5xx ─────────────────────────────────────────────────────────────
101
102    #[test]
103    fn target_error_500() {
104        assert_eq!(
105            classify(500, b"Internal Server Error"),
106            BodyVerdict::TargetError
107        );
108    }
109
110    #[test]
111    fn target_error_502() {
112        assert_eq!(classify(502, b"Bad Gateway"), BodyVerdict::TargetError);
113    }
114
115    #[test]
116    fn target_error_503() {
117        assert_eq!(
118            classify(503, b"Service Unavailable"),
119            BodyVerdict::TargetError
120        );
121    }
122
123    // ── Other status codes ──────────────────────────────────────────────
124
125    #[test]
126    fn proxy_blocked_301_redirect() {
127        assert_eq!(classify(301, b"Moved"), BodyVerdict::ProxyBlocked);
128    }
129
130    #[test]
131    fn proxy_blocked_400_bad_request() {
132        assert_eq!(classify(400, b"Bad Request"), BodyVerdict::ProxyBlocked);
133    }
134
135    #[test]
136    fn proxy_blocked_401_unauthorised() {
137        assert_eq!(classify(401, b"Unauthorised"), BodyVerdict::ProxyBlocked);
138    }
139
140    #[test]
141    fn proxy_blocked_404_not_found() {
142        assert_eq!(classify(404, b"Not Found"), BodyVerdict::ProxyBlocked);
143    }
144
145    #[test]
146    fn proxy_blocked_407_proxy_auth_required() {
147        assert_eq!(
148            classify(407, b"Proxy Authentication Required"),
149            BodyVerdict::ProxyBlocked
150        );
151    }
152
153    // ── Headers are forwarded (even if DefaultClassifier ignores them) ──
154
155    #[test]
156    fn headers_are_available_to_classifier() {
157        let classifier = DefaultClassifier;
158        let mut headers = HeaderMap::new();
159        headers.insert("x-custom", "value".parse().unwrap());
160        // Should still produce Success for 200 + body regardless of headers.
161        assert_eq!(
162            classifier.classify(StatusCode::OK, &headers, b"data"),
163            BodyVerdict::Success,
164        );
165    }
166
167    // ── Trait object safety ─────────────────────────────────────────────
168
169    #[test]
170    fn can_be_used_as_trait_object() {
171        let classifier: Box<dyn BodyClassifier> = Box::new(DefaultClassifier);
172        let headers = HeaderMap::new();
173        assert_eq!(
174            classifier.classify(StatusCode::OK, &headers, b"body"),
175            BodyVerdict::Success,
176        );
177    }
178
179    // ── Custom classifier ───────────────────────────────────────────────
180
181    struct AlwaysSuccess;
182
183    impl BodyClassifier for AlwaysSuccess {
184        fn classify(&self, _status: StatusCode, _headers: &HeaderMap, _body: &[u8]) -> BodyVerdict {
185            BodyVerdict::Success
186        }
187    }
188
189    #[test]
190    fn custom_classifier_overrides_defaults() {
191        let classifier = AlwaysSuccess;
192        let headers = HeaderMap::new();
193        // Even a 500 is considered success by AlwaysSuccess.
194        assert_eq!(
195            classifier.classify(StatusCode::INTERNAL_SERVER_ERROR, &headers, b""),
196            BodyVerdict::Success,
197        );
198    }
199
200    // ── BodyVerdict derives ─────────────────────────────────────────────
201
202    #[test]
203    fn verdict_is_copy_and_clone() {
204        let v = BodyVerdict::Success;
205        let v2 = v; // Copy
206        let v3 = v; // Clone (Copy)
207        assert_eq!(v, v2);
208        assert_eq!(v2, v3);
209    }
210
211    #[test]
212    fn verdict_debug_format() {
213        assert_eq!(format!("{:?}", BodyVerdict::Success), "Success");
214        assert_eq!(format!("{:?}", BodyVerdict::ProxyBlocked), "ProxyBlocked");
215        assert_eq!(format!("{:?}", BodyVerdict::TargetError), "TargetError");
216    }
217}