Skip to main content

scatter_proxy/
classifier.rs

1use http::{HeaderMap, StatusCode};
2
3/// Four-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/// - `Challenge` — a WAF challenge page was returned; the scheduler will attempt to
10///   resolve it on the *same* proxy node via [`ChallengeResolver`], then retry.
11///   Without a resolver configured, `Challenge` degrades to `ProxyBlocked`.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum BodyVerdict {
14    Success,
15    ProxyBlocked,
16    TargetError,
17    /// WAF challenge page — resolve on the same proxy, then retry.
18    Challenge,
19}
20
21/// Trait that inspects a completed HTTP response and returns a [`BodyVerdict`].
22///
23/// Implement this to customise how your target site's responses are interpreted.
24pub trait BodyClassifier: Send + Sync + 'static {
25    fn classify(&self, status: StatusCode, headers: &HeaderMap, body: &[u8]) -> BodyVerdict;
26}
27
28/// Built-in classifier that covers the common case:
29///
30/// | Condition | Verdict |
31/// |-----------|---------|
32/// | 2xx **and** non-empty body | `Success` |
33/// | 2xx **and** empty body | `ProxyBlocked` |
34/// | 403 or 429 | `ProxyBlocked` |
35/// | 5xx | `TargetError` |
36/// | everything else | `ProxyBlocked` |
37pub struct DefaultClassifier;
38
39impl BodyClassifier for DefaultClassifier {
40    fn classify(&self, status: StatusCode, _headers: &HeaderMap, body: &[u8]) -> BodyVerdict {
41        if status.is_success() {
42            if body.is_empty() {
43                BodyVerdict::ProxyBlocked
44            } else {
45                BodyVerdict::Success
46            }
47        } else if status == StatusCode::FORBIDDEN || status == StatusCode::TOO_MANY_REQUESTS {
48            BodyVerdict::ProxyBlocked
49        } else if status.is_server_error() {
50            BodyVerdict::TargetError
51        } else {
52            BodyVerdict::ProxyBlocked
53        }
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    /// Helper that classifies with the default classifier, empty headers, and the
62    /// supplied status + body.
63    fn classify(status: u16, body: &[u8]) -> BodyVerdict {
64        let classifier = DefaultClassifier;
65        let headers = HeaderMap::new();
66        classifier.classify(StatusCode::from_u16(status).unwrap(), &headers, body)
67    }
68
69    // ── 2xx ──────────────────────────────────────────────────────────────
70
71    #[test]
72    fn success_200_with_body() {
73        assert_eq!(classify(200, b"hello"), BodyVerdict::Success);
74    }
75
76    #[test]
77    fn success_201_with_body() {
78        assert_eq!(classify(201, b"{\"id\":1}"), BodyVerdict::Success);
79    }
80
81    #[test]
82    fn success_204_no_content_empty_body() {
83        // 204 is 2xx but body is empty → ProxyBlocked per the default rules.
84        assert_eq!(classify(204, b""), BodyVerdict::ProxyBlocked);
85    }
86
87    #[test]
88    fn proxy_blocked_200_empty_body() {
89        assert_eq!(classify(200, b""), BodyVerdict::ProxyBlocked);
90    }
91
92    // ── 403 / 429 ───────────────────────────────────────────────────────
93
94    #[test]
95    fn proxy_blocked_403() {
96        assert_eq!(classify(403, b"Forbidden"), BodyVerdict::ProxyBlocked);
97    }
98
99    #[test]
100    fn proxy_blocked_429() {
101        assert_eq!(classify(429, b"Rate limited"), BodyVerdict::ProxyBlocked);
102    }
103
104    // ── 5xx ─────────────────────────────────────────────────────────────
105
106    #[test]
107    fn target_error_500() {
108        assert_eq!(
109            classify(500, b"Internal Server Error"),
110            BodyVerdict::TargetError
111        );
112    }
113
114    #[test]
115    fn target_error_502() {
116        assert_eq!(classify(502, b"Bad Gateway"), BodyVerdict::TargetError);
117    }
118
119    #[test]
120    fn target_error_503() {
121        assert_eq!(
122            classify(503, b"Service Unavailable"),
123            BodyVerdict::TargetError
124        );
125    }
126
127    // ── Other status codes ──────────────────────────────────────────────
128
129    #[test]
130    fn proxy_blocked_301_redirect() {
131        assert_eq!(classify(301, b"Moved"), BodyVerdict::ProxyBlocked);
132    }
133
134    #[test]
135    fn proxy_blocked_400_bad_request() {
136        assert_eq!(classify(400, b"Bad Request"), BodyVerdict::ProxyBlocked);
137    }
138
139    #[test]
140    fn proxy_blocked_401_unauthorised() {
141        assert_eq!(classify(401, b"Unauthorised"), BodyVerdict::ProxyBlocked);
142    }
143
144    #[test]
145    fn proxy_blocked_404_not_found() {
146        assert_eq!(classify(404, b"Not Found"), BodyVerdict::ProxyBlocked);
147    }
148
149    #[test]
150    fn proxy_blocked_407_proxy_auth_required() {
151        assert_eq!(
152            classify(407, b"Proxy Authentication Required"),
153            BodyVerdict::ProxyBlocked
154        );
155    }
156
157    // ── Headers are forwarded (even if DefaultClassifier ignores them) ──
158
159    #[test]
160    fn headers_are_available_to_classifier() {
161        let classifier = DefaultClassifier;
162        let mut headers = HeaderMap::new();
163        headers.insert("x-custom", "value".parse().unwrap());
164        // Should still produce Success for 200 + body regardless of headers.
165        assert_eq!(
166            classifier.classify(StatusCode::OK, &headers, b"data"),
167            BodyVerdict::Success,
168        );
169    }
170
171    // ── Trait object safety ─────────────────────────────────────────────
172
173    #[test]
174    fn can_be_used_as_trait_object() {
175        let classifier: Box<dyn BodyClassifier> = Box::new(DefaultClassifier);
176        let headers = HeaderMap::new();
177        assert_eq!(
178            classifier.classify(StatusCode::OK, &headers, b"body"),
179            BodyVerdict::Success,
180        );
181    }
182
183    // ── Custom classifier ───────────────────────────────────────────────
184
185    struct AlwaysSuccess;
186
187    impl BodyClassifier for AlwaysSuccess {
188        fn classify(&self, _status: StatusCode, _headers: &HeaderMap, _body: &[u8]) -> BodyVerdict {
189            BodyVerdict::Success
190        }
191    }
192
193    #[test]
194    fn custom_classifier_overrides_defaults() {
195        let classifier = AlwaysSuccess;
196        let headers = HeaderMap::new();
197        // Even a 500 is considered success by AlwaysSuccess.
198        assert_eq!(
199            classifier.classify(StatusCode::INTERNAL_SERVER_ERROR, &headers, b""),
200            BodyVerdict::Success,
201        );
202    }
203
204    // ── BodyVerdict derives ─────────────────────────────────────────────
205
206    #[test]
207    fn verdict_is_copy_and_clone() {
208        let v = BodyVerdict::Success;
209        let v2 = v; // Copy
210        let v3 = v; // Clone (Copy)
211        assert_eq!(v, v2);
212        assert_eq!(v2, v3);
213    }
214
215    #[test]
216    fn verdict_debug_format() {
217        assert_eq!(format!("{:?}", BodyVerdict::Success), "Success");
218        assert_eq!(format!("{:?}", BodyVerdict::ProxyBlocked), "ProxyBlocked");
219        assert_eq!(format!("{:?}", BodyVerdict::TargetError), "TargetError");
220        assert_eq!(format!("{:?}", BodyVerdict::Challenge), "Challenge");
221    }
222
223    // ── Challenge variant ───────────────────────────────────────────────
224
225    struct WafChallengeClassifier;
226
227    impl BodyClassifier for WafChallengeClassifier {
228        fn classify(&self, status: StatusCode, _headers: &HeaderMap, body: &[u8]) -> BodyVerdict {
229            if status.is_success() {
230                if body.starts_with(b"<script>") {
231                    BodyVerdict::Challenge
232                } else if body.is_empty() {
233                    BodyVerdict::ProxyBlocked
234                } else {
235                    BodyVerdict::Success
236                }
237            } else {
238                BodyVerdict::ProxyBlocked
239            }
240        }
241    }
242
243    #[test]
244    fn custom_classifier_returns_challenge() {
245        let classifier = WafChallengeClassifier;
246        let headers = HeaderMap::new();
247        assert_eq!(
248            classifier.classify(StatusCode::OK, &headers, b"<script>var x=1</script>"),
249            BodyVerdict::Challenge,
250        );
251    }
252
253    #[test]
254    fn challenge_variant_is_eq() {
255        assert_eq!(BodyVerdict::Challenge, BodyVerdict::Challenge);
256        assert_ne!(BodyVerdict::Challenge, BodyVerdict::ProxyBlocked);
257    }
258}