Skip to main content

parlov_core/
response_class.rs

1//! Response classification for Phase 2 harvest admission gating.
2//!
3//! Maps an HTTP `(StatusCode, &HeaderMap)` pair to one of the eight discrete signal families
4//! the harvest model is built around. Use `ResponseClass::classify` — never match on raw status
5//! codes at call sites.
6
7use http::{header, HeaderMap, StatusCode};
8
9/// Discrete signal families used to gate harvest admission in Phase 2.
10///
11/// Always construct via [`ResponseClass::classify`] — never match on raw status codes.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum ResponseClass {
14    /// 206 Partial Content — range request satisfied.
15    PartialContent,
16    /// 304 Not Modified — conditional request matched; no body returned.
17    NotModified,
18    /// 416 Range Not Satisfiable — range request out of bounds.
19    RangeNotSatisfiable,
20    /// 401 Unauthorized or 407 Proxy Authentication Required.
21    AuthChallenge,
22    /// 429 Too Many Requests or 503 Service Unavailable.
23    RateLimited,
24    /// 4xx with `Content-Type: application/problem+json` or `application/json`.
25    StructuredError,
26    /// 3xx with a `Location` header present.
27    Redirect,
28    /// 2xx excluding 206.
29    Success,
30    /// Anything not covered by the above variants.
31    Other,
32}
33
34impl ResponseClass {
35    /// Classifies an HTTP response into one of the eight signal families.
36    ///
37    /// Top-to-bottom; first match wins. Precedence: 206 before `Success`, 304 before `Redirect`,
38    /// 401/429 before `StructuredError`.
39    #[must_use]
40    pub fn classify(status: StatusCode, headers: &HeaderMap) -> Self {
41        if status == StatusCode::PARTIAL_CONTENT {
42            return Self::PartialContent;
43        }
44        if status == StatusCode::NOT_MODIFIED {
45            return Self::NotModified;
46        }
47        if status == StatusCode::RANGE_NOT_SATISFIABLE {
48            return Self::RangeNotSatisfiable;
49        }
50        if status == StatusCode::UNAUTHORIZED || status == StatusCode::PROXY_AUTHENTICATION_REQUIRED
51        {
52            return Self::AuthChallenge;
53        }
54        if status == StatusCode::TOO_MANY_REQUESTS || status == StatusCode::SERVICE_UNAVAILABLE {
55            return Self::RateLimited;
56        }
57        if status.is_client_error() && has_json_content_type(headers) {
58            return Self::StructuredError;
59        }
60        if status.is_redirection() && headers.contains_key(header::LOCATION) {
61            return Self::Redirect;
62        }
63        if status.is_success() {
64            return Self::Success;
65        }
66        Self::Other
67    }
68}
69
70/// Returns `true` if `Content-Type` contains `application/problem+json` or `application/json`.
71///
72/// A missing or non-UTF-8 value returns `false`.
73fn has_json_content_type(headers: &HeaderMap) -> bool {
74    headers
75        .get(header::CONTENT_TYPE)
76        .and_then(|v| v.to_str().ok())
77        .is_some_and(|ct| {
78            ct.contains("application/problem+json") || ct.contains("application/json")
79        })
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use http::HeaderValue;
86
87    fn empty_headers() -> HeaderMap {
88        HeaderMap::new()
89    }
90
91    fn headers_with_content_type(ct: &str) -> HeaderMap {
92        let mut map = HeaderMap::new();
93        map.insert(
94            header::CONTENT_TYPE,
95            HeaderValue::from_str(ct).expect("valid header value"),
96        );
97        map
98    }
99
100    fn headers_with_location(url: &str) -> HeaderMap {
101        let mut map = HeaderMap::new();
102        map.insert(
103            header::LOCATION,
104            HeaderValue::from_str(url).expect("valid header value"),
105        );
106        map
107    }
108
109    // --- Basic variant coverage ---
110
111    #[test]
112    fn partial_content_206() {
113        assert_eq!(
114            ResponseClass::classify(StatusCode::PARTIAL_CONTENT, &empty_headers()),
115            ResponseClass::PartialContent
116        );
117    }
118
119    #[test]
120    fn not_modified_304() {
121        assert_eq!(
122            ResponseClass::classify(StatusCode::NOT_MODIFIED, &empty_headers()),
123            ResponseClass::NotModified
124        );
125    }
126
127    #[test]
128    fn range_not_satisfiable_416() {
129        assert_eq!(
130            ResponseClass::classify(StatusCode::RANGE_NOT_SATISFIABLE, &empty_headers()),
131            ResponseClass::RangeNotSatisfiable
132        );
133    }
134
135    #[test]
136    fn auth_challenge_401() {
137        assert_eq!(
138            ResponseClass::classify(StatusCode::UNAUTHORIZED, &empty_headers()),
139            ResponseClass::AuthChallenge
140        );
141    }
142
143    #[test]
144    fn auth_challenge_407() {
145        assert_eq!(
146            ResponseClass::classify(StatusCode::PROXY_AUTHENTICATION_REQUIRED, &empty_headers()),
147            ResponseClass::AuthChallenge
148        );
149    }
150
151    #[test]
152    fn rate_limited_429() {
153        assert_eq!(
154            ResponseClass::classify(StatusCode::TOO_MANY_REQUESTS, &empty_headers()),
155            ResponseClass::RateLimited
156        );
157    }
158
159    #[test]
160    fn rate_limited_503() {
161        assert_eq!(
162            ResponseClass::classify(StatusCode::SERVICE_UNAVAILABLE, &empty_headers()),
163            ResponseClass::RateLimited
164        );
165    }
166
167    #[test]
168    fn structured_error_problem_json() {
169        assert_eq!(
170            ResponseClass::classify(
171                StatusCode::BAD_REQUEST,
172                &headers_with_content_type("application/problem+json")
173            ),
174            ResponseClass::StructuredError
175        );
176    }
177
178    #[test]
179    fn structured_error_application_json() {
180        assert_eq!(
181            ResponseClass::classify(
182                StatusCode::UNPROCESSABLE_ENTITY,
183                &headers_with_content_type("application/json")
184            ),
185            ResponseClass::StructuredError
186        );
187    }
188
189    #[test]
190    fn redirect_301_with_location() {
191        assert_eq!(
192            ResponseClass::classify(
193                StatusCode::MOVED_PERMANENTLY,
194                &headers_with_location("https://example.com/new")
195            ),
196            ResponseClass::Redirect
197        );
198    }
199
200    #[test]
201    fn success_200() {
202        assert_eq!(
203            ResponseClass::classify(StatusCode::OK, &empty_headers()),
204            ResponseClass::Success
205        );
206    }
207
208    #[test]
209    fn success_201() {
210        assert_eq!(
211            ResponseClass::classify(StatusCode::CREATED, &empty_headers()),
212            ResponseClass::Success
213        );
214    }
215
216    #[test]
217    fn other_500() {
218        assert_eq!(
219            ResponseClass::classify(StatusCode::INTERNAL_SERVER_ERROR, &empty_headers()),
220            ResponseClass::Other
221        );
222    }
223
224    #[test]
225    fn other_100() {
226        assert_eq!(
227            ResponseClass::classify(StatusCode::CONTINUE, &empty_headers()),
228            ResponseClass::Other
229        );
230    }
231
232    // --- Precedence cases ---
233
234    #[test]
235    fn precedence_206_not_success() {
236        // 206 is a 2xx — must match PartialContent before Success
237        assert_eq!(
238            ResponseClass::classify(StatusCode::PARTIAL_CONTENT, &empty_headers()),
239            ResponseClass::PartialContent
240        );
241    }
242
243    #[test]
244    fn precedence_304_not_redirect_even_with_location() {
245        // 304 is a 3xx — must match NotModified before Redirect even when Location is set
246        assert_eq!(
247            ResponseClass::classify(
248                StatusCode::NOT_MODIFIED,
249                &headers_with_location("https://example.com/new")
250            ),
251            ResponseClass::NotModified
252        );
253    }
254
255    #[test]
256    fn precedence_401_not_structured_error_with_json_content_type() {
257        // 401 is a 4xx — must match AuthChallenge before StructuredError
258        assert_eq!(
259            ResponseClass::classify(
260                StatusCode::UNAUTHORIZED,
261                &headers_with_content_type("application/json")
262            ),
263            ResponseClass::AuthChallenge
264        );
265    }
266
267    #[test]
268    fn precedence_429_not_structured_error_with_problem_json() {
269        // 429 is a 4xx — must match RateLimited before StructuredError
270        assert_eq!(
271            ResponseClass::classify(
272                StatusCode::TOO_MANY_REQUESTS,
273                &headers_with_content_type("application/problem+json")
274            ),
275            ResponseClass::RateLimited
276        );
277    }
278
279    #[test]
280    fn other_4xx_without_json_content_type() {
281        // 400 with no Content-Type is not StructuredError
282        assert_eq!(
283            ResponseClass::classify(StatusCode::BAD_REQUEST, &empty_headers()),
284            ResponseClass::Other
285        );
286    }
287
288    #[test]
289    fn other_3xx_without_location() {
290        // 301 with no Location header is not Redirect
291        assert_eq!(
292            ResponseClass::classify(StatusCode::MOVED_PERMANENTLY, &empty_headers()),
293            ResponseClass::Other
294        );
295    }
296}