toolkit_http/response.rs
1use crate::error::HttpError;
2use crate::security::ERROR_BODY_PREVIEW_LIMIT;
3use bytes::Bytes;
4use http::{HeaderMap, Response, StatusCode};
5use http_body::Frame;
6use http_body_util::BodyExt;
7use pin_project_lite::pin_project;
8use serde::de::DeserializeOwned;
9use std::pin::Pin;
10use std::task::{Context, Poll};
11use std::time::{Duration, SystemTime};
12
13/// Parse `Retry-After` header value into a `Duration`.
14///
15/// Supports two formats per RFC 7231:
16/// - Seconds: "120" → 120 seconds
17/// - HTTP-date (RFC 1123): "Wed, 21 Oct 2015 07:28:00 GMT" → duration until that time
18///
19/// Returns `None` if:
20/// - Header is missing
21/// - Value cannot be parsed as integer or HTTP-date
22/// - Parsed duration is negative (time already passed or negative seconds)
23pub fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
24 let value = headers.get(http::header::RETRY_AFTER)?.to_str().ok()?;
25 let trimmed = value.trim();
26
27 // First, try to parse as seconds (most common format)
28 if let Ok(seconds) = trimmed.parse::<i64>() {
29 if seconds < 0 {
30 return None;
31 }
32 return Some(Duration::from_secs(seconds.cast_unsigned()));
33 }
34
35 // Fall back to HTTP-date format (RFC 1123)
36 parse_http_date(trimmed)
37}
38
39/// Parse HTTP-date (RFC 1123) and return duration until that time.
40/// Returns `None` if the date is in the past or cannot be parsed.
41fn parse_http_date(value: &str) -> Option<Duration> {
42 let parsed = httpdate::parse_http_date(value).ok()?;
43 let now = SystemTime::now();
44
45 // Return duration until the parsed time (None if already passed)
46 parsed.duration_since(now).ok()
47}
48
49/// Type alias for the boxed response body that supports decompression.
50///
51/// This type can hold either a raw body or a decompressed body (gzip/br/deflate).
52/// The body is type-erased to allow the decompression layer to work transparently.
53pub type ResponseBody =
54 http_body_util::combinators::BoxBody<Bytes, Box<dyn std::error::Error + Send + Sync>>;
55
56pin_project! {
57 /// Body wrapper that enforces size limits during streaming.
58 ///
59 /// Created by [`HttpResponse::into_limited_body()`]. Tracks bytes read
60 /// and returns [`HttpError::BodyTooLarge`] if the limit is exceeded.
61 ///
62 /// Unlike reading the body with [`HttpResponse::bytes()`] or [`HttpResponse::json()`],
63 /// this allows incremental processing while still enforcing limits.
64 ///
65 /// # Example
66 ///
67 /// ```ignore
68 /// use http_body_util::BodyExt;
69 ///
70 /// let response = client.get("https://example.com/large-file").send().await?;
71 /// let mut body = response.into_limited_body();
72 ///
73 /// while let Some(frame) = body.frame().await {
74 /// let frame = frame?; // Returns BodyTooLarge if limit exceeded
75 /// if let Some(chunk) = frame.data_ref() {
76 /// process_chunk(chunk);
77 /// }
78 /// }
79 /// ```
80 pub struct LimitedBody {
81 #[pin]
82 inner: ResponseBody,
83 limit: usize,
84 read: usize,
85 }
86}
87
88impl LimitedBody {
89 /// Creates a new `LimitedBody` wrapping the given body with the specified limit.
90 #[must_use]
91 pub fn new(inner: ResponseBody, limit: usize) -> Self {
92 Self {
93 inner,
94 limit,
95 read: 0,
96 }
97 }
98
99 /// Returns the number of bytes read so far.
100 #[must_use]
101 pub fn bytes_read(&self) -> usize {
102 self.read
103 }
104
105 /// Returns the configured size limit.
106 #[must_use]
107 pub fn limit(&self) -> usize {
108 self.limit
109 }
110}
111
112impl http_body::Body for LimitedBody {
113 type Data = Bytes;
114 type Error = HttpError;
115
116 fn poll_frame(
117 self: Pin<&mut Self>,
118 cx: &mut Context<'_>,
119 ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
120 let this = self.project();
121
122 match this.inner.poll_frame(cx) {
123 Poll::Ready(Some(Ok(frame))) => {
124 if let Some(data) = frame.data_ref() {
125 *this.read += data.len();
126 if *this.read > *this.limit {
127 return Poll::Ready(Some(Err(HttpError::BodyTooLarge {
128 limit: *this.limit,
129 actual: *this.read,
130 })));
131 }
132 }
133 Poll::Ready(Some(Ok(frame)))
134 }
135 Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(HttpError::Transport(e)))),
136 Poll::Ready(None) => Poll::Ready(None),
137 Poll::Pending => Poll::Pending,
138 }
139 }
140}
141
142/// HTTP response wrapper with body-reading helpers
143///
144/// Provides a reqwest-like API for reading response bodies:
145/// - `resp.error_for_status()?` - Check status without reading body
146/// - `resp.bytes().await?` - Read raw bytes
147/// - `resp.checked_bytes().await?` - Read bytes with status check
148/// - `resp.json::<T>().await?` - Parse as JSON with status check
149///
150/// All body reads enforce the configured `max_body_size` limit.
151#[derive(Debug)]
152pub struct HttpResponse {
153 /// The underlying HTTP response.
154 pub(crate) inner: Response<ResponseBody>,
155 /// Maximum number of body bytes that may be read into memory.
156 pub(crate) max_body_size: usize,
157}
158
159impl HttpResponse {
160 /// Get the response status code
161 #[must_use]
162 pub fn status(&self) -> StatusCode {
163 self.inner.status()
164 }
165
166 /// Get the response headers
167 #[must_use]
168 pub fn headers(&self) -> &HeaderMap {
169 self.inner.headers()
170 }
171
172 /// Consume the wrapper and return the inner response with boxed body
173 ///
174 /// Useful for advanced callers who need direct access to the response.
175 /// Note: The body has already been through the decompression layer,
176 /// so it contains decompressed bytes if the server sent compressed data.
177 #[must_use]
178 pub fn into_inner(self) -> Response<ResponseBody> {
179 self.inner
180 }
181
182 /// Check status and return error for non-2xx responses
183 ///
184 /// Does NOT read the response body. For non-2xx status, returns
185 /// `HttpError::HttpStatus` with an empty body preview.
186 ///
187 /// # Errors
188 ///
189 /// Returns `HttpError::HttpStatus` if the response status is not 2xx.
190 ///
191 /// # Example
192 ///
193 /// ```ignore
194 /// let resp = client.get("https://example.com/api").send().await?;
195 /// let resp = resp.error_for_status()?; // Fails if not 2xx
196 /// let body = resp.bytes().await?;
197 /// ```
198 pub fn error_for_status(self) -> Result<Self, HttpError> {
199 if self.inner.status().is_success() {
200 return Ok(self);
201 }
202
203 let content_type = self
204 .inner
205 .headers()
206 .get(http::header::CONTENT_TYPE)
207 .and_then(|v| v.to_str().ok())
208 .map(String::from);
209
210 let retry_after = parse_retry_after(self.inner.headers());
211
212 Err(HttpError::HttpStatus {
213 status: self.inner.status(),
214 body_preview: String::new(),
215 content_type,
216 retry_after,
217 })
218 }
219
220 /// Read response body as bytes without status check
221 ///
222 /// Enforces `max_body_size` limit.
223 ///
224 /// # Errors
225 /// Returns `HttpError::BodyTooLarge` if body exceeds limit.
226 pub async fn bytes(self) -> Result<Bytes, HttpError> {
227 read_body_limited_impl(self.inner, self.max_body_size).await
228 }
229
230 /// Read response body as bytes with status check
231 ///
232 /// Returns `HttpError::HttpStatus` for non-2xx responses (with body preview).
233 /// Enforces `max_body_size` limit for successful responses.
234 ///
235 /// # Errors
236 /// Returns `HttpError::HttpStatus` if status is not 2xx.
237 /// Returns `HttpError::BodyTooLarge` if body exceeds limit.
238 pub async fn checked_bytes(self) -> Result<Bytes, HttpError> {
239 checked_body_impl(self.inner, self.max_body_size).await
240 }
241
242 /// Parse response body as JSON with status check
243 ///
244 /// Equivalent to `resp.checked_bytes().await?` followed by JSON parsing.
245 ///
246 /// # Errors
247 /// Returns `HttpError::HttpStatus` if status is not 2xx.
248 /// Returns `HttpError::BodyTooLarge` if body exceeds limit.
249 /// Returns `HttpError::Json` if parsing fails.
250 pub async fn json<T: DeserializeOwned>(self) -> Result<T, HttpError> {
251 let body_bytes = checked_body_impl(self.inner, self.max_body_size).await?;
252 let value = serde_json::from_slice(&body_bytes)?;
253 Ok(value)
254 }
255
256 /// Read response body as text (UTF-8) with status check
257 ///
258 /// Equivalent to `resp.checked_bytes().await?` followed by UTF-8 conversion.
259 /// Invalid UTF-8 sequences are replaced with the Unicode replacement character.
260 ///
261 /// # Errors
262 /// Returns `HttpError::HttpStatus` if status is not 2xx.
263 /// Returns `HttpError::BodyTooLarge` if body exceeds limit.
264 ///
265 /// # Example
266 ///
267 /// ```ignore
268 /// let body = client
269 /// .get("https://example.com/text")
270 /// .send()
271 /// .await?
272 /// .text()
273 /// .await?;
274 /// println!("Response: {}", body);
275 /// ```
276 pub async fn text(self) -> Result<String, HttpError> {
277 let body_bytes = checked_body_impl(self.inner, self.max_body_size).await?;
278 Ok(String::from_utf8_lossy(&body_bytes).into_owned())
279 }
280
281 /// Returns the response body as a stream for incremental processing.
282 ///
283 /// # Warning: No Size Limit Enforcement
284 ///
285 /// This method does **NOT** enforce `max_body_size`. For untrusted responses
286 /// (especially compressed), prefer [`into_limited_body()`](Self::into_limited_body)
287 /// to protect against decompression bombs and memory exhaustion.
288 ///
289 /// Unlike `bytes()`, `json()`, or `text()`, this method does NOT:
290 /// - Check the HTTP status code (use `error_for_status()` first if needed)
291 /// - Enforce the `max_body_size` limit (caller is responsible for limiting)
292 /// - Buffer the entire body in memory
293 ///
294 /// Use this only when:
295 /// - You trust the response source AND have external size limits
296 /// - You're implementing custom streaming logic with your own limits
297 /// - Performance is critical and you can guarantee bounded responses
298 ///
299 /// # Example
300 ///
301 /// ```ignore
302 /// use http_body_util::BodyExt;
303 ///
304 /// let response = client.get("https://example.com/large-file").send().await?;
305 ///
306 /// // Check status first (optional)
307 /// if !response.status().is_success() {
308 /// return Err(/* handle error */);
309 /// }
310 ///
311 /// // Get the body stream (WARNING: no size limit!)
312 /// let mut body = response.into_body();
313 ///
314 /// // Process frames incrementally
315 /// while let Some(frame) = body.frame().await {
316 /// let frame = frame?;
317 /// if let Some(chunk) = frame.data_ref() {
318 /// process_chunk(chunk);
319 /// }
320 /// }
321 /// ```
322 #[must_use]
323 pub fn into_body(self) -> ResponseBody {
324 self.inner.into_body()
325 }
326
327 /// Returns the response body as a size-limited stream.
328 ///
329 /// Unlike [`into_body()`](Self::into_body), this method wraps the body in a
330 /// [`LimitedBody`] that enforces the configured `max_body_size` limit during
331 /// streaming. This protects against decompression bombs where a small compressed
332 /// payload expands to gigabytes of memory.
333 ///
334 /// The limit is enforced on **decompressed** bytes, so a 1KB gzip payload that
335 /// decompresses to 1GB will be rejected.
336 ///
337 /// # Errors
338 ///
339 /// When the limit is exceeded, the next `poll_frame()` call returns
340 /// `HttpError::BodyTooLarge`.
341 ///
342 /// # Example
343 ///
344 /// ```ignore
345 /// use http_body_util::BodyExt;
346 ///
347 /// let response = client.get("https://example.com/large-file").send().await?;
348 /// let mut body = response.into_limited_body();
349 ///
350 /// while let Some(frame) = body.frame().await {
351 /// let frame = frame?; // Returns BodyTooLarge if limit exceeded
352 /// if let Some(chunk) = frame.data_ref() {
353 /// process_chunk(chunk);
354 /// }
355 /// }
356 ///
357 /// println!("Total bytes read: {}", body.bytes_read());
358 /// ```
359 #[must_use]
360 pub fn into_limited_body(self) -> LimitedBody {
361 LimitedBody::new(self.inner.into_body(), self.max_body_size)
362 }
363
364 /// Returns the configured max body size for this response.
365 ///
366 /// This is the limit that would be applied by `bytes()`, `checked_bytes()`,
367 /// `json()`, and `text()` methods.
368 #[must_use]
369 pub fn max_body_size(&self) -> usize {
370 self.max_body_size
371 }
372}
373
374/// Internal implementation of `checked_body` that doesn't capture `&self`
375pub async fn checked_body_impl(
376 response: Response<ResponseBody>,
377 max_body_size: usize,
378) -> Result<Bytes, HttpError> {
379 let status = response.status();
380 let content_type = response
381 .headers()
382 .get(http::header::CONTENT_TYPE)
383 .and_then(|v| v.to_str().ok())
384 .map(String::from);
385
386 if !status.is_success() {
387 // Parse Retry-After header before consuming response
388 let retry_after = parse_retry_after(response.headers());
389
390 // Read limited preview for error message
391 // Handle BodyTooLarge gracefully - don't let it hide the HTTP status error
392 let preview_limit = max_body_size.min(ERROR_BODY_PREVIEW_LIMIT);
393 let body_preview = match read_body_limited_impl(response, preview_limit).await {
394 Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
395 Err(HttpError::BodyTooLarge { .. }) => "<body too large for preview>".to_owned(),
396 Err(e) => return Err(e), // Propagate transport errors
397 };
398
399 return Err(HttpError::HttpStatus {
400 status,
401 body_preview,
402 content_type,
403 retry_after,
404 });
405 }
406
407 read_body_limited_impl(response, max_body_size).await
408}
409
410/// Internal implementation of `read_body_limited` that doesn't capture `&self`
411///
412/// This function reads from the (potentially decompressed) response body,
413/// enforcing the byte limit on decompressed data. This protects against
414/// decompression bombs where a small compressed payload expands to gigabytes.
415pub async fn read_body_limited_impl(
416 response: Response<ResponseBody>,
417 limit: usize,
418) -> Result<Bytes, HttpError> {
419 let (_parts, body) = response.into_parts();
420
421 let mut collected = Vec::new();
422 let mut body = std::pin::pin!(body);
423
424 while let Some(frame) = body.frame().await {
425 let frame = frame.map_err(HttpError::Transport)?;
426 if let Some(chunk) = frame.data_ref() {
427 if collected.len() + chunk.len() > limit {
428 return Err(HttpError::BodyTooLarge {
429 limit,
430 actual: collected.len() + chunk.len(),
431 });
432 }
433 collected.extend_from_slice(chunk);
434 }
435 }
436
437 Ok(Bytes::from(collected))
438}
439
440#[cfg(test)]
441#[cfg_attr(coverage_nightly, coverage(off))]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn test_parse_retry_after_seconds() {
447 let mut headers = HeaderMap::new();
448 headers.insert(http::header::RETRY_AFTER, "120".parse().unwrap());
449
450 let result = parse_retry_after(&headers);
451 assert_eq!(result, Some(Duration::from_mins(2)));
452 }
453
454 #[test]
455 fn test_parse_retry_after_seconds_with_whitespace() {
456 let mut headers = HeaderMap::new();
457 headers.insert(http::header::RETRY_AFTER, " 60 ".parse().unwrap());
458
459 let result = parse_retry_after(&headers);
460 assert_eq!(result, Some(Duration::from_mins(1)));
461 }
462
463 #[test]
464 fn test_parse_retry_after_missing() {
465 let headers = HeaderMap::new();
466 let result = parse_retry_after(&headers);
467 assert_eq!(result, None);
468 }
469
470 #[test]
471 fn test_parse_retry_after_invalid() {
472 let mut headers = HeaderMap::new();
473 headers.insert(http::header::RETRY_AFTER, "not-a-number".parse().unwrap());
474
475 let result = parse_retry_after(&headers);
476 assert_eq!(result, None);
477 }
478
479 #[test]
480 fn test_parse_retry_after_http_date_in_past() {
481 let mut headers = HeaderMap::new();
482 // HTTP-date in the past returns None
483 headers.insert(
484 http::header::RETRY_AFTER,
485 "Wed, 21 Oct 2015 07:28:00 GMT".parse().unwrap(),
486 );
487
488 let result = parse_retry_after(&headers);
489 assert_eq!(result, None);
490 }
491
492 #[test]
493 fn test_parse_retry_after_http_date_in_future() {
494 let mut headers = HeaderMap::new();
495 // Create a date 60 seconds in the future
496 let future_time = SystemTime::now() + Duration::from_mins(1);
497 let http_date = httpdate::fmt_http_date(future_time);
498 headers.insert(http::header::RETRY_AFTER, http_date.parse().unwrap());
499
500 let result = parse_retry_after(&headers);
501 assert!(result.is_some());
502 // Should be approximately 60 seconds (with some tolerance for test execution)
503 let duration = result.unwrap();
504 assert!(duration.as_secs() >= 58 && duration.as_secs() <= 62);
505 }
506
507 #[test]
508 fn test_parse_retry_after_negative_seconds() {
509 let mut headers = HeaderMap::new();
510 headers.insert(http::header::RETRY_AFTER, "-5".parse().unwrap());
511
512 let result = parse_retry_after(&headers);
513 assert_eq!(result, None);
514 }
515
516 #[test]
517 fn test_parse_retry_after_zero() {
518 let mut headers = HeaderMap::new();
519 headers.insert(http::header::RETRY_AFTER, "0".parse().unwrap());
520
521 let result = parse_retry_after(&headers);
522 assert_eq!(result, Some(Duration::from_secs(0)));
523 }
524}