Skip to main content

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}