Skip to main content

cratefield_core/ports/
http.rs

1//! The `HttpClient` port (architecture section 5): plain `http` types over
2//! bytes, so adapters (Resend, Turnstile) run unchanged on Workers
3//! (`worker::Fetch`) and native (`reqwest`, phase 3).
4//!
5//! # Contract (issue #136)
6//!
7//! An outbound request is the one place a module or adapter can make the
8//! runtime allocate or wait on behalf of an upstream it does not control,
9//! so the port itself defines the bounds:
10//!
11//! 1. **Response size.** An implementation MUST refuse a response whose
12//!    body exceeds the effective [`HttpPolicy`] cap, with
13//!    [`HttpError::ResponseTooLarge`] — and MUST check the declared
14//!    `Content-Length` before consuming the body, so a hostile length does
15//!    not cost an allocation. A caller can only ever *lower* the cap
16//!    below [`MAX_RESPONSE_BYTES`]. The one exemption is a
17//!    [`StatusOnly`] request, which reads no body at all.
18//! 2. **Deadline.** Every send is bounded by the effective
19//!    [`HttpPolicy::timeout`]; an upstream that has not answered by then
20//!    is abandoned (not awaited) with [`HttpError::DeadlineExceeded`], so
21//!    a hung service cannot pin a Worker request or a native tenant. The
22//!    native runtime enforces this inside `reqwest` (and thereby against
23//!    each redirect hop); the Workers runtime cannot time `worker::Fetch`
24//!    itself, so it is enforced through the [`Clock`](super::Clock) port by
25//!    [`BoundedHttpClient`], which every runtime wires around its
26//!    implementation.
27//! 3. **Destinations.** An implementation that opens real sockets MUST
28//!    refuse destinations a caller-supplied URL must never reach — non
29//!    `http(s)` schemes, userinfo, and loopback, private, link-local,
30//!    multicast or cloud-metadata addresses (169.254.169.254 and
31//!    friends, in IPv6 and decimal/octal/hex encodings alike) — with
32//!    [`HttpError::BlockedDestination`], and MUST re-vet **every redirect
33//!    hop** rather than trusting the first destination. On Workers this
34//!    falls to the platform, whose `fetch` refuses non-public
35//!    destinations; the native runtime implements the vetting itself.
36//!    An implementation that follows no redirects at all satisfies this
37//!    by construction — the Workers port asks for `redirect: "manual"`
38//!    and hands the caller the 3xx itself (issue #714).
39//! 4. **Concurrency budget.** A runtime with a shared outbound client MUST
40//!    cap in-flight requests at [`MAX_CONCURRENT_REQUESTS`] per process
41//!    (one native process serves one tenant, so that cap *is* the
42//!    per-tenant budget) and refuse past it rather than queue unbounded
43//!    work.
44//!
45//! Bounds are per-operation and travel as a request extension
46//! ([`HttpPolicy::of_request`]); the extensions never reach the wire,
47//! implementations read them before rebuilding the transport request.
48//!
49//! # Streaming (issue #859)
50//!
51//! [`HttpClient::send_streaming`] is the same port with the body
52//! unbuffered: the head (status and headers) is known when the call
53//! returns and the bytes arrive chunk by chunk afterwards. The head is
54//! still bounded by [`HttpPolicy::timeout`] (at most
55//! [`MAX_RESPONSE_TIMEOUT`]); the **body** cannot be, because its whole
56//! point is to outlive a short exchange — so it is bounded instead by the
57//! quiet gap between two chunks ([`StreamPolicy::idle_timeout`]) and by a
58//! whole-body ceiling ([`StreamPolicy::total_timeout`]), both carried by
59//! [`StreamPolicy`] the same way: a request extension, clamped so a
60//! caller may only tighten.
61
62use std::sync::Arc;
63use std::time::Duration;
64
65use async_trait::async_trait;
66use bytes::Bytes;
67use futures_util::{FutureExt as _, StreamExt};
68use thiserror::Error;
69
70use super::Clock;
71use crate::ports::timeout;
72use crate::stream::BoxStream;
73
74/// Hard ceiling on a response body this port may return: an adapter's
75/// largest legitimate upstream reply (JWKS documents, Stripe objects,
76/// LinkedIn `people` payloads) is orders of magnitude below it, and it is
77/// far below anything that threatens isolate memory. Callers may lower it
78/// per operation, never raise it.
79pub const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
80
81/// Deadline applied to an outbound call that asks for none. Longer than
82/// the strictest in-tree self-bound (Turnstile's 5s verify), shorter than
83/// anything that could pin a request.
84pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
85
86/// Ceiling on a caller-supplied deadline. A 30 s Worker request cannot
87/// responsibly spend more than half of it waiting on one upstream.
88pub const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
89
90/// Per-process budget for concurrent outbound requests. The native
91/// runtime shares one connection pool across the tenant it serves; this
92/// is what stops one request's fan-out (say a cron fanning out emails)
93/// from monopolising the pool and the response-buffer memory behind it.
94pub const MAX_CONCURRENT_REQUESTS: usize = 32;
95
96/// How quiet a streamed body may go — the largest gap between two chunks —
97/// before [`BoundedHttpClient`] declares the upstream stalled and
98/// abandons it. Long enough for a model's between-token pauses, short
99/// enough that a hung connection cannot pin a request for the body's
100/// whole ceiling.
101pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(60);
102
103/// Ceiling on a caller-supplied [`StreamPolicy::idle_timeout`]: a
104/// quiet-for-longer body is the caller's problem to justify, and never
105/// this port's.
106pub const MAX_STREAM_IDLE_TIMEOUT: Duration = Duration::from_mins(5);
107
108/// Default whole-body ceiling for a streamed response. An order of
109/// magnitude past [`DEFAULT_RESPONSE_TIMEOUT`], because the body's whole
110/// point is to keep arriving; far below anything that could pin a
111/// request's other work for a working day.
112pub const DEFAULT_STREAM_TOTAL_TIMEOUT: Duration = Duration::from_mins(10);
113
114/// Ceiling on a caller-supplied [`StreamPolicy::total_timeout`]: half an
115/// hour of streamed body is the most any single exchange may ask for.
116pub const MAX_STREAM_TOTAL_TIMEOUT: Duration = Duration::from_mins(30);
117
118/// Hard ceiling on a streamed response body, the streaming twin of
119/// [`MAX_RESPONSE_BYTES`]: a body that will not fit an isolate's memory is
120/// refused at the chunk that would cross it, and a caller may only lower
121/// the cap, never raise it.
122pub const MAX_STREAM_BYTES: usize = 64 * 1024 * 1024;
123
124/// Per-operation bounds on a **streamed** response body, attached to a
125/// request as an extension and resolved by every implementation with
126/// [`StreamPolicy::of_request`], the way [`HttpPolicy`] is. All fields
127/// clamp to the port maxima: a caller may only tighten.
128///
129/// The two timeouts divide the exchange along its one natural seam. The
130/// **head** — status and headers, everything known before the first byte
131/// of body — is bounded by [`HttpPolicy::timeout`] (at most 30 s,
132/// unchanged), because an upstream that cannot even answer is dead in the
133/// buffered world too. The **body** is bounded instead by
134/// [`idle_timeout`](Self::idle_timeout), the quiet gap between two chunks
135/// (a model's between-token pauses are normal; a stalled connection is
136/// not), and by [`total_timeout`](Self::total_timeout), the whole body's
137/// ceiling measured from the head's return. A body that streams steadily
138/// for twenty minutes is legal here and impossible under the 30 s cap —
139/// that is the feature, not a hole. Whichever bound ends the body names
140/// itself: the [`HttpError::DeadlineExceeded`] carries the idle gap when
141/// a quiet gap expired, and the whole-body ceiling when that is what the
142/// exchange ran out of.
143///
144/// [`max_bytes`](Self::max_bytes) is enforced chunk by chunk (and against
145/// the declared `Content-Length` before the first chunk), the way the
146/// buffered cap is enforced against the whole body at once.
147///
148/// # Cloudflare Workers
149///
150/// On the Workers runtime these bounds are what makes streaming survivable
151/// there at all, because the platform's own limits shape what a streamed
152/// exchange may cost (the numbers move; the authoritative page is
153/// <https://developers.cloudflare.com/workers/platform/limits/>):
154///
155/// - **There is no wall-clock limit on a request while the client stays
156///   connected**, and time spent waiting on I/O does not count as CPU
157///   time — so a body that keeps arriving may legitimately run far past
158///   any CPU budget, and the port's own idle and total ceilings are the
159///   only thing standing between a streaming caller and a connection the
160///   runtime would otherwise keep open indefinitely.
161/// - **Subrequests per invocation are capped** — a few dozen on the Free
162///   plan and a great many more on paid plans when this port was written
163///   (50 / 1,000; the paid ceiling has since been raised and made
164///   configurable) — and every hop of a redirect chain counts as one. A
165///   retry loop over streamed responses spends the budget on the head, as
166///   the buffered port always has.
167/// - **At most 6 simultaneous open connections per invocation.** A
168///   streamed exchange holds one of those six slots for its whole life,
169///   which under an idle-timeout-quota of minutes is a long time to hold
170///   a sixth of a request's concurrency: the total ceiling exists to keep
171///   even a healthy stream from monopolising the pool.
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub struct StreamPolicy {
174    /// Largest gap allowed between two body chunks before the body is
175    /// declared stalled and abandoned.
176    pub idle_timeout: Duration,
177    /// Whole-body ceiling, measured from the moment the head returned.
178    pub total_timeout: Duration,
179    /// Maximum accepted body size, enforced chunk by chunk.
180    pub max_bytes: usize,
181}
182
183impl Default for StreamPolicy {
184    fn default() -> Self {
185        Self {
186            idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
187            total_timeout: DEFAULT_STREAM_TOTAL_TIMEOUT,
188            max_bytes: MAX_STREAM_BYTES,
189        }
190    }
191}
192
193impl StreamPolicy {
194    /// The effective policy for `request`: the attached [`StreamPolicy`]
195    /// extension if any, clamped to the port ceilings.
196    #[must_use]
197    pub fn of_request<T>(request: &http::Request<T>) -> Self {
198        request
199            .extensions()
200            .get::<StreamPolicy>()
201            .copied()
202            .unwrap_or_default()
203            .clamped()
204    }
205
206    /// Clamps to [`MAX_STREAM_IDLE_TIMEOUT`] /
207    /// [`MAX_STREAM_TOTAL_TIMEOUT`] / [`MAX_STREAM_BYTES`]: callers may
208    /// lower a bound, never raise it.
209    #[must_use]
210    pub fn clamped(self) -> Self {
211        Self {
212            idle_timeout: self.idle_timeout.min(MAX_STREAM_IDLE_TIMEOUT),
213            total_timeout: self.total_timeout.min(MAX_STREAM_TOTAL_TIMEOUT),
214            max_bytes: self.max_bytes.min(MAX_STREAM_BYTES),
215        }
216    }
217}
218
219/// A streamed response body: the chunk shape
220/// [`HttpClient::send_streaming`] returns behind a response's head.
221/// Dropping it aborts the upstream exchange (see the method's contract).
222pub type ByteStream = BoxStream<'static, Result<Bytes, HttpError>>;
223
224/// Per-operation outbound bounds, attached to a request as an extension
225/// and resolved by every implementation with [`HttpPolicy::of_request`].
226/// Both fields clamp to the port maxima: a caller may only tighten.
227#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228pub struct HttpPolicy {
229    /// Maximum accepted response body size.
230    pub max_response_bytes: usize,
231    /// Maximum time to wait for the upstream to answer.
232    pub timeout: Duration,
233}
234
235impl Default for HttpPolicy {
236    fn default() -> Self {
237        Self {
238            max_response_bytes: MAX_RESPONSE_BYTES,
239            timeout: DEFAULT_RESPONSE_TIMEOUT,
240        }
241    }
242}
243
244impl HttpPolicy {
245    /// The effective policy for `request`: the attached [`HttpPolicy`]
246    /// extension if any, clamped to the port ceilings.
247    #[must_use]
248    pub fn of_request<T>(request: &http::Request<T>) -> Self {
249        request
250            .extensions()
251            .get::<HttpPolicy>()
252            .copied()
253            .unwrap_or_default()
254            .clamped()
255    }
256
257    /// Clamps to [`MAX_RESPONSE_BYTES`] / [`MAX_RESPONSE_TIMEOUT`]:
258    /// callers may lower a bound, never raise it.
259    #[must_use]
260    pub fn clamped(self) -> Self {
261        Self {
262            max_response_bytes: self.max_response_bytes.min(MAX_RESPONSE_BYTES),
263            timeout: self.timeout.min(MAX_RESPONSE_TIMEOUT),
264        }
265    }
266}
267
268/// Marks a request whose caller wants only the status line. An
269/// implementation that honours it returns a response with no body and no
270/// body-describing headers (`Content-Length`, `Content-Encoding`,
271/// `Transfer-Encoding`), because there is no body here to describe.
272///
273/// Cheaper than `HEAD` when the caller already knows the endpoint and
274/// only needs to know whether it answered — a liveness probe of a
275/// customer's webhook URL, say, where a multi-megabyte body is a
276/// liability and its absence is no information at all. The extensions
277/// never reach the wire: implementations read this before rebuilding the
278/// transport request, exactly as [`HttpPolicy`] is read.
279///
280/// Honoured today by the Workers port (`cratefield-runtime-cloudflare`'s
281/// `FetchClient`). The native port does not read the extension and
282/// answers such a request like any other; the marker asks, it does not
283/// oblige.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct StatusOnly;
286
287/// The declared `Content-Length` of a response, when it parses as a
288/// non-negative size. Implementations check this before touching the
289/// body, so an oversized declared length is refused without allocating
290/// for it (issue #136).
291#[must_use]
292pub fn declared_content_length(headers: &http::HeaderMap) -> Option<usize> {
293    headers
294        .get(http::header::CONTENT_LENGTH)
295        .and_then(|value| value.to_str().ok())
296        .and_then(|value| value.parse::<usize>().ok())
297}
298
299#[derive(Debug, Clone, Error)]
300pub enum HttpError {
301    #[error("http request failed: {0}")]
302    Transport(String),
303    /// The upstream's response body exceeded the [`HttpPolicy`] cap.
304    #[error("upstream response exceeds the {limit}-byte bound")]
305    ResponseTooLarge {
306        /// The effective cap that was exceeded.
307        limit: usize,
308    },
309    /// The upstream did not answer within the [`HttpPolicy`] deadline and
310    /// was abandoned.
311    #[error("upstream deadline of {after:?} exceeded")]
312    DeadlineExceeded {
313        /// The effective deadline.
314        after: Duration,
315    },
316    /// The destination (or one of its redirect hops) is refused by the
317    /// outbound policy: scheme, userinfo, or a loopback / private /
318    /// link-local / metadata address.
319    #[error("destination refused by outbound policy: {0}")]
320    BlockedDestination(String),
321    /// The outbound budget refused the request before it reached the wire:
322    /// the per-upstream token bucket could not carry its cost within the
323    /// wait this client allows, or the per-provider daily quota is spent.
324    /// `retry_after` is when the budget can carry the request again — the
325    /// same contract a 429's `Retry-After` carries, for a request that was
326    /// never sent (issue #765).
327    #[error("outbound budget exhausted for {what}: retry after {retry_after:?}")]
328    BudgetExhausted {
329        /// What the budget is spent against — the budget router's key, an
330        /// upstream name or `rpc:<key id>` shaped string.
331        what: String,
332        /// How long until the budget can carry the request again.
333        retry_after: Duration,
334    },
335}
336
337#[async_trait]
338pub trait HttpClient: Send + Sync {
339    /// Sends `request` and returns the fully-buffered response under the
340    /// bounds this port carries: the body is at most
341    /// [`MAX_RESPONSE_BYTES`] unless an [`HttpPolicy`] tightens it, the
342    /// exchange is bounded by that policy's deadline
343    /// (enforced through [`BoundedHttpClient`] on runtimes that wrap),
344    /// the destination is vetted where real sockets are opened, and a
345    /// runtime that keeps a concurrency budget holds one for the send.
346    ///
347    /// An implementation MAY honour a [`StatusOnly`] request extension by
348    /// answering with no body and no body-describing headers; an
349    /// implementation that does not read the extension answers it like
350    /// any other request. The Workers port honours it today.
351    async fn send(&self, request: http::Request<Bytes>)
352    -> Result<http::Response<Bytes>, HttpError>;
353
354    /// Sends `request` and returns the response with its **body as a
355    /// stream** (issue #859): the status and headers are known when the
356    /// future resolves — the head is bounded by [`HttpPolicy::timeout`],
357    /// the same deadline `send` answers under — and the bytes arrive chunk
358    /// by chunk afterwards, under the [`StreamPolicy`] the request carries.
359    ///
360    /// The body's bounds — the idle gap, the whole-body ceiling, the
361    /// [`StreamPolicy::max_bytes`] cap and the declared-length refusal —
362    /// are [`BoundedHttpClient`]'s, which every runtime wires around its
363    /// transport; an implementation MAY enforce some of them itself (the
364    /// native port applies the byte cap while streaming), and one that
365    /// does not streams unbounded. Callers reach a transport only through
366    /// the runtime's wrapper, so the bounds hold on every supported path.
367    ///
368    /// # Cancellation
369    ///
370    /// Dropping the returned body MUST abort the upstream exchange. This
371    /// is what makes cancellation affordable on this port: a route whose
372    /// client disconnects mid-completion drops the stream, the transport
373    /// drops the request, and the upstream stops billing for a body
374    /// nobody is reading. The [`BoundedHttpClient`] wrapper preserves the
375    /// property on expiry and over-cap paths by dropping the inner body
376    /// it wraps.
377    ///
378    /// The default implementation buffers: it calls [`Self::send`] and
379    /// yields the whole body as one chunk. That is correct — every bound
380    /// still holds — but it is not incremental, so a transport that can
381    /// stream natively SHOULD override this; a caller cannot tell the
382    /// difference except by timing.
383    async fn send_streaming(
384        &self,
385        request: http::Request<Bytes>,
386    ) -> Result<http::Response<ByteStream>, HttpError> {
387        let (parts, body) = self.send(request).await?.into_parts();
388        let body: ByteStream = Box::pin(futures_util::stream::once(async move { Ok(body) }));
389        Ok(http::Response::from_parts(parts, body))
390    }
391}
392
393/// Wraps any [`HttpClient`] so the port's bounds hold on every runtime:
394/// the deadline is enforced through the [`Clock`](super::Clock) port (the
395/// only timer core is allowed to use), and the response is refused — by
396/// declared length before anything else, then by actual size — when it
397/// exceeds the [`HttpPolicy`] cap.
398///
399/// This is defence in depth, not the only defence: the native adapter
400/// also enforces the cap while streaming (before the oversized body is
401/// ever buffered) and the Workers adapter checks the declared length and
402/// then reads the body as a stream under the same cap, aborting at the
403/// chunk that crosses it. Runtimes wire every `ports.http` through
404/// this wrapper.
405///
406/// Streaming (`send_streaming`) is bounded the same way, with the body's
407/// own seam: the head under the [`HttpPolicy`] deadline, the body under
408/// the [`StreamPolicy`]'s idle gap, whole-body ceiling and byte cap — all
409/// measured through the same clock, all dropping the inner body (and with
410/// it the upstream exchange) on the way out. Whichever time bound ends
411/// the body names itself in the [`HttpError::DeadlineExceeded`] it
412/// raises: the idle gap when a quiet gap was what expired, the whole-body
413/// ceiling when that did.
414pub struct BoundedHttpClient {
415    inner: Arc<dyn HttpClient>,
416    clock: Arc<dyn Clock>,
417}
418
419impl BoundedHttpClient {
420    /// Bounds `inner`'s sends by `clock`-measured deadlines and caps.
421    #[must_use]
422    pub fn new(inner: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
423        Self { inner, clock }
424    }
425}
426
427#[async_trait]
428impl HttpClient for BoundedHttpClient {
429    async fn send(
430        &self,
431        request: http::Request<Bytes>,
432    ) -> Result<http::Response<Bytes>, HttpError> {
433        let policy = HttpPolicy::of_request(&request);
434        let inner = Arc::clone(&self.inner);
435        let Some(outcome) = timeout(
436            self.clock.as_ref(),
437            async move { inner.send(request).await },
438            policy.timeout,
439        )
440        .await
441        else {
442            return Err(HttpError::DeadlineExceeded {
443                after: policy.timeout,
444            });
445        };
446        let response = outcome?;
447        if declared_content_length(response.headers())
448            .is_some_and(|len| len > policy.max_response_bytes)
449        {
450            return Err(HttpError::ResponseTooLarge {
451                limit: policy.max_response_bytes,
452            });
453        }
454        if response.body().len() > policy.max_response_bytes {
455            return Err(HttpError::ResponseTooLarge {
456                limit: policy.max_response_bytes,
457            });
458        }
459        Ok(response)
460    }
461
462    async fn send_streaming(
463        &self,
464        request: http::Request<Bytes>,
465    ) -> Result<http::Response<ByteStream>, HttpError> {
466        // The head answers under the buffered port's deadline: an upstream
467        // that cannot even produce headers is dead in the streaming world
468        // too, and no body exists yet to abandon gracefully.
469        let policy = HttpPolicy::of_request(&request);
470        let stream_policy = StreamPolicy::of_request(&request);
471        let inner = Arc::clone(&self.inner);
472        let Some(outcome) = timeout(
473            self.clock.as_ref(),
474            async move { inner.send_streaming(request).await },
475            policy.timeout,
476        )
477        .await
478        else {
479            return Err(HttpError::DeadlineExceeded {
480                after: policy.timeout,
481            });
482        };
483        let (parts, body) = outcome?.into_parts();
484        // A declared length over the cap is refused before the first
485        // chunk, exactly as the buffered port refuses one before the body
486        // is consumed: a hostile length must not cost an allocation.
487        if declared_content_length(&parts.headers).is_some_and(|len| len > stream_policy.max_bytes)
488        {
489            return Err(HttpError::ResponseTooLarge {
490                limit: stream_policy.max_bytes,
491            });
492        }
493        // The body's whole-life ceiling, fixed now and measured against
494        // the same clock at every chunk, so the bound holds whatever the
495        // transport does or does not time itself.
496        let total =
497            time::Duration::try_from(stream_policy.total_timeout).unwrap_or(time::Duration::MAX);
498        let deadline = self.clock.now().saturating_add(total);
499        let body: ByteStream = Box::pin(futures_util::stream::unfold(
500            BoundedBody {
501                stream: Some(body),
502                clock: Arc::clone(&self.clock),
503                deadline,
504                idle_timeout: stream_policy.idle_timeout,
505                total_timeout: stream_policy.total_timeout,
506                max_bytes: stream_policy.max_bytes,
507                delivered: 0,
508                done: false,
509            },
510            |mut state| async move {
511                if state.done {
512                    return None;
513                }
514                // Unreachable while `done` is false, but the compiler
515                // cannot know that: no stream left means the body is over.
516                let mut stream = state.stream.take()?;
517                // The whole-body ceiling first: past the deadline, even a
518                // chunk arriving on time is refused. The *end* of the body
519                // is not a chunk, though — it owes nobody a wait — so the
520                // expiry poll is non-blocking: a body that already ended
521                // ends cleanly here (all of it was delivered inside its
522                // own time), an item already in hand would carry the body
523                // past the ceiling and is refused, and anything still
524                // pending has genuinely run out of time.
525                let remaining: Duration = (state.deadline - state.clock.now())
526                    .try_into()
527                    .unwrap_or(Duration::ZERO);
528                if remaining.is_zero() {
529                    state.done = true;
530                    let expired = HttpError::DeadlineExceeded {
531                        after: state.total_timeout,
532                    };
533                    // `stream` drops on every arm, aborting the upstream
534                    // exchange.
535                    return match stream.next().now_or_never() {
536                        Some(None) => None,
537                        Some(Some(_)) | None => Some((Err(expired), state)),
538                    };
539                }
540                // Each chunk is bounded by the quieter of the idle gap and
541                // what the whole-body ceiling still allows.
542                let budget = state.idle_timeout.min(remaining);
543                // `timeout` needs a `'static` future, and the stream must
544                // survive the poll to be polled again: it travels into the
545                // poll and comes back out with its item. A clock that gives
546                // up drops the future — and the stream with it, which is
547                // the cancellation contract.
548                let Some((stream, item)) = timeout(
549                    state.clock.as_ref(),
550                    async move {
551                        let item = stream.next().await;
552                        (stream, item)
553                    },
554                    budget,
555                )
556                .await
557                else {
558                    state.done = true;
559                    // Name the bound that was in force: the body's own idle
560                    // gap when that is what the poll was up against, the
561                    // whole-body ceiling when the remaining allowance is
562                    // what cut it short.
563                    let after = if state.idle_timeout <= remaining {
564                        state.idle_timeout
565                    } else {
566                        state.total_timeout
567                    };
568                    return Some((Err(HttpError::DeadlineExceeded { after }), state));
569                };
570                match item {
571                    // A clean end: the body dropped itself, `None` ends
572                    // the wrapper.
573                    None => None,
574                    Some(Err(err)) => {
575                        state.done = true;
576                        Some((Err(err), state))
577                    }
578                    Some(Ok(chunk)) => {
579                        if state.delivered.saturating_add(chunk.len()) > state.max_bytes {
580                            // The crossing chunk is refused, not delivered,
581                            // and the body drops with the upstream.
582                            state.done = true;
583                            return Some((
584                                Err(HttpError::ResponseTooLarge {
585                                    limit: state.max_bytes,
586                                }),
587                                state,
588                            ));
589                        }
590                        state.delivered += chunk.len();
591                        state.stream = Some(stream);
592                        Some((Ok(chunk), state))
593                    }
594                }
595            },
596        ));
597        Ok(http::Response::from_parts(parts, body))
598    }
599}
600
601/// The `unfold` state behind [`BoundedHttpClient::send_streaming`]'s body:
602/// the inner chunk stream plus the clock, deadline and counters the three
603/// body bounds are enforced with. `done` fuses the wrapper after its one
604/// terminal error; `stream` is `None` exactly while a chunk poll is in
605/// flight, which is also how the inner body gets dropped on expiry.
606struct BoundedBody {
607    stream: Option<ByteStream>,
608    clock: Arc<dyn Clock>,
609    deadline: time::OffsetDateTime,
610    idle_timeout: Duration,
611    total_timeout: Duration,
612    max_bytes: usize,
613    delivered: usize,
614    done: bool,
615}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620    use http::Response;
621
622    /// Policy resolution is the port's whole vocabulary; pin the clamps.
623    #[test]
624    fn a_policy_clamps_to_the_port_ceilings() {
625        let loose = HttpPolicy {
626            max_response_bytes: usize::MAX,
627            timeout: Duration::from_secs(3600),
628        };
629        let clamped = loose.clamped();
630        assert_eq!(clamped.max_response_bytes, MAX_RESPONSE_BYTES);
631        assert_eq!(clamped.timeout, MAX_RESPONSE_TIMEOUT);
632
633        let tight = HttpPolicy {
634            max_response_bytes: 1024,
635            timeout: Duration::from_millis(50),
636        };
637        assert_eq!(tight.clamped(), tight, "tightening is honoured");
638
639        let defaulted = HttpPolicy::of_request(&http::Request::new(Bytes::new()));
640        assert_eq!(defaulted, HttpPolicy::default());
641        let mut request = http::Request::new(Bytes::new());
642        request.extensions_mut().insert(HttpPolicy {
643            max_response_bytes: usize::MAX,
644            timeout: Duration::from_secs(3600),
645        });
646        let resolved = HttpPolicy::of_request(&request);
647        assert_eq!(resolved.max_response_bytes, MAX_RESPONSE_BYTES);
648        assert_eq!(resolved.timeout, MAX_RESPONSE_TIMEOUT);
649    }
650
651    /// The inner client is never asked to police itself: a body over the
652    /// cap, by declared length or by actual size, is refused here.
653    struct ScriptedClient {
654        response: Result<Response<Bytes>, HttpError>,
655    }
656
657    #[async_trait]
658    impl HttpClient for ScriptedClient {
659        async fn send(&self, _request: http::Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
660            self.response.clone()
661        }
662    }
663
664    struct NoTimerClock;
665
666    #[async_trait]
667    impl Clock for NoTimerClock {
668        fn now(&self) -> time::OffsetDateTime {
669            time::OffsetDateTime::UNIX_EPOCH
670        }
671        // default timeout_any: runs to completion — the wrapper's own
672        // checks must then catch the violation.
673    }
674
675    struct AlwaysTimeoutClock;
676
677    #[async_trait]
678    impl Clock for AlwaysTimeoutClock {
679        fn now(&self) -> time::OffsetDateTime {
680            time::OffsetDateTime::UNIX_EPOCH
681        }
682        async fn timeout_any(
683            &self,
684            fut: futures_core::future::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
685            _after: Duration,
686        ) -> Option<Box<dyn std::any::Any + Send>> {
687            drop(fut);
688            None
689        }
690    }
691
692    fn bounded(inner: ScriptedClient, clock: Arc<dyn Clock>) -> BoundedHttpClient {
693        BoundedHttpClient::new(Arc::new(inner), clock)
694    }
695
696    #[pollster::test]
697    async fn an_oversized_response_body_is_refused() {
698        let inner = ScriptedClient {
699            response: Ok(Response::new(Bytes::from(vec![0_u8; 4_096]))),
700        };
701        let mut request = http::Request::new(Bytes::new());
702        request.extensions_mut().insert(HttpPolicy {
703            max_response_bytes: 1_024,
704            timeout: Duration::from_secs(5),
705        });
706        let err = bounded(inner, Arc::new(NoTimerClock))
707            .send(request)
708            .await
709            .expect_err("4 KiB over a 1 KiB cap must be refused");
710        assert!(
711            matches!(err, HttpError::ResponseTooLarge { limit: 1_024 }),
712            "got {err}"
713        );
714    }
715
716    #[pollster::test]
717    async fn an_oversized_declared_length_is_refused_before_the_body_counts() {
718        // Actual body is small, but the declared length lies about a huge
719        // response; the wrapper refuses on the header alone.
720        let inner = ScriptedClient {
721            response: Ok(Response::builder()
722                .header(http::header::CONTENT_LENGTH, "9000")
723                .body(Bytes::from_static(b"hi"))
724                .expect("test response")),
725        };
726        let mut request = http::Request::new(Bytes::new());
727        request.extensions_mut().insert(HttpPolicy {
728            max_response_bytes: 1_024,
729            timeout: Duration::from_secs(5),
730        });
731        let err = bounded(inner, Arc::new(NoTimerClock))
732            .send(request)
733            .await
734            .expect_err("a declared 9 KB over a 1 KiB cap must be refused");
735        assert!(
736            matches!(err, HttpError::ResponseTooLarge { .. }),
737            "got {err}"
738        );
739    }
740
741    #[pollster::test]
742    async fn an_abandoned_send_is_a_deadline_not_a_hang() {
743        // The clock gives up on the future (its contract: abandon, do not
744        // await); the wrapper maps that to DeadlineExceeded with the
745        // effective duration, even though the inner would have answered.
746        let inner = ScriptedClient {
747            response: Ok(Response::new(Bytes::from_static(b"late"))),
748        };
749        let err = bounded(inner, Arc::new(AlwaysTimeoutClock))
750            .send(http::Request::new(Bytes::new()))
751            .await
752            .expect_err("an abandoned send must surface as the deadline");
753        assert!(
754            matches!(&err, HttpError::DeadlineExceeded { after } if *after == DEFAULT_RESPONSE_TIMEOUT),
755            "got {err}"
756        );
757    }
758
759    // -----------------------------------------------------------------
760    // StreamPolicy (issue #859)
761
762    /// A stream policy clamps exactly like the buffered one: tighten all
763    /// you like, raise nothing past the port ceilings.
764    // A day spelled in seconds is the point: it is the value being clamped.
765    #[allow(clippy::duration_suboptimal_units)]
766    #[test]
767    fn a_stream_policy_clamps_to_the_port_ceilings() {
768        let loose = StreamPolicy {
769            idle_timeout: Duration::from_secs(3600),
770            total_timeout: Duration::from_secs(86_400),
771            max_bytes: usize::MAX,
772        };
773        let clamped = loose.clamped();
774        assert_eq!(clamped.idle_timeout, MAX_STREAM_IDLE_TIMEOUT);
775        assert_eq!(clamped.total_timeout, MAX_STREAM_TOTAL_TIMEOUT);
776        assert_eq!(clamped.max_bytes, MAX_STREAM_BYTES);
777
778        let tight = StreamPolicy {
779            idle_timeout: Duration::from_secs(5),
780            total_timeout: Duration::from_secs(30),
781            max_bytes: 1_024,
782        };
783        assert_eq!(tight.clamped(), tight, "tightening is honoured");
784
785        let defaulted = StreamPolicy::of_request(&http::Request::new(Bytes::new()));
786        assert_eq!(defaulted, StreamPolicy::default());
787        let mut request = http::Request::new(Bytes::new());
788        request.extensions_mut().insert(StreamPolicy {
789            idle_timeout: Duration::from_secs(3600),
790            total_timeout: Duration::from_secs(86_400),
791            max_bytes: usize::MAX,
792        });
793        let resolved = StreamPolicy::of_request(&request);
794        assert_eq!(resolved.idle_timeout, MAX_STREAM_IDLE_TIMEOUT);
795        assert_eq!(resolved.total_timeout, MAX_STREAM_TOTAL_TIMEOUT);
796        assert_eq!(resolved.max_bytes, MAX_STREAM_BYTES);
797    }
798
799    // -----------------------------------------------------------------
800    // Streaming bodies
801
802    /// A client that streams natively: every `send_streaming` hands back a
803    /// body delivering `chunks` one by one, counting how many any caller
804    /// actually pulled (a refused head or a dropped body leaves it at
805    /// zero — the assertion that a body was never consumed).
806    struct StreamingClient {
807        chunks: Vec<Vec<u8>>,
808        content_length: Option<&'static str>,
809        consumed: Arc<std::sync::atomic::AtomicUsize>,
810    }
811
812    impl StreamingClient {
813        fn new(chunks: Vec<Vec<u8>>) -> Arc<Self> {
814            Arc::new(Self {
815                chunks,
816                content_length: None,
817                consumed: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
818            })
819        }
820
821        /// A variant whose head declares a (possibly lying) length.
822        fn with_content_length(chunks: Vec<Vec<u8>>, length: &'static str) -> Arc<Self> {
823            Arc::new(Self {
824                chunks,
825                content_length: Some(length),
826                consumed: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
827            })
828        }
829
830        fn consumed(&self) -> usize {
831            self.consumed.load(std::sync::atomic::Ordering::Relaxed)
832        }
833    }
834
835    #[async_trait]
836    impl HttpClient for StreamingClient {
837        async fn send(&self, _request: http::Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
838            Ok(Response::new(Bytes::from(self.chunks.concat())))
839        }
840
841        async fn send_streaming(
842            &self,
843            _request: http::Request<Bytes>,
844        ) -> Result<Response<ByteStream>, HttpError> {
845            let mut builder = Response::builder();
846            if let Some(length) = self.content_length {
847                builder = builder.header(http::header::CONTENT_LENGTH, length);
848            }
849            let response = builder.body(()).expect("test response head");
850            let consumed = Arc::clone(&self.consumed);
851            // Copied up front: the stream is `'static`, the fixture is not.
852            let chunks = self.chunks.clone();
853            let body: ByteStream = Box::pin(
854                futures_util::stream::iter(chunks.into_iter().map(|chunk| Ok(Bytes::from(chunk))))
855                    .inspect(move |_| {
856                        consumed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
857                    }),
858            );
859            Ok(response.map(move |()| body))
860        }
861    }
862
863    /// A clock whose `timeout_any` runs the first future to completion and
864    /// abandons every later one — the head gets through, the body's first
865    /// poll does not. Deterministic stand-in for a stalled upstream.
866    struct HeadThenAbandon(std::sync::atomic::AtomicUsize);
867
868    #[async_trait]
869    impl Clock for HeadThenAbandon {
870        fn now(&self) -> time::OffsetDateTime {
871            time::OffsetDateTime::UNIX_EPOCH
872        }
873        async fn timeout_any(
874            &self,
875            fut: futures_core::future::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
876            _after: Duration,
877        ) -> Option<Box<dyn std::any::Any + Send>> {
878            if self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed) == 0 {
879                Some(fut.await)
880            } else {
881                drop(fut);
882                None
883            }
884        }
885    }
886
887    /// A clock whose wall reading advances `step_secs` every time it is
888    /// read — the only way time passes. `timeout_any` keeps the default
889    /// run-to-completion behaviour, so what bounds a stream is exactly the
890    /// wrapper's own deadline arithmetic.
891    struct AdvancingClock {
892        step_secs: i64,
893        ticks: std::sync::atomic::AtomicUsize,
894    }
895
896    impl AdvancingClock {
897        fn new(step_secs: i64) -> Arc<Self> {
898            Arc::new(Self {
899                step_secs,
900                ticks: std::sync::atomic::AtomicUsize::new(0),
901            })
902        }
903    }
904
905    impl Clock for AdvancingClock {
906        fn now(&self) -> time::OffsetDateTime {
907            let tick = i64::try_from(
908                self.ticks
909                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed),
910            )
911            .unwrap_or(i64::MAX);
912            time::OffsetDateTime::UNIX_EPOCH + time::Duration::seconds(tick * self.step_secs)
913        }
914    }
915
916    fn streaming_request(policy: Option<StreamPolicy>) -> http::Request<Bytes> {
917        let mut request = http::Request::new(Bytes::new());
918        if let Some(policy) = policy {
919            request.extensions_mut().insert(policy);
920        }
921        request
922    }
923
924    /// Pulls a body to its end, collecting what it yielded.
925    async fn drain(mut body: crate::ports::http::ByteStream) -> Vec<Result<Bytes, HttpError>> {
926        let mut items = Vec::new();
927        while let Some(item) = body.next().await {
928            items.push(item);
929        }
930        items
931    }
932
933    #[pollster::test]
934    async fn the_default_send_streaming_buffers_into_one_chunk() {
935        // A client that never opted into streaming answers through the
936        // default: one `send`, one chunk, bounds intact.
937        let inner = ScriptedClient {
938            response: Ok(Response::new(Bytes::from_static(b"buffered whole"))),
939        };
940        let client = BoundedHttpClient::new(Arc::new(inner), Arc::new(NoTimerClock));
941        let response = client
942            .send_streaming(http::Request::new(Bytes::new()))
943            .await
944            .expect("buffered fallback streams");
945        let mut items = drain(response.into_body()).await;
946        assert_eq!(items.len(), 1, "one round trip, one chunk: {items:?}");
947        assert_eq!(items.remove(0).unwrap(), &b"buffered whole"[..]);
948    }
949
950    #[pollster::test]
951    async fn a_streaming_head_abandoned_by_the_clock_is_a_deadline() {
952        // The head is bounded by the buffered port's deadline, whatever
953        // the body would have done.
954        let inner = StreamingClient::new(vec![b"a".to_vec(), b"b".to_vec()]);
955        let err = match BoundedHttpClient::new(inner.clone(), Arc::new(AlwaysTimeoutClock))
956            .send_streaming(http::Request::new(Bytes::new()))
957            .await
958        {
959            // No `Debug` on a streaming response, so `expect_err` cannot
960            // name this arm — a `match` refuses it just as loudly.
961            Ok(response) => {
962                drop(response);
963                panic!("an abandoned head must surface as the deadline");
964            }
965            Err(err) => err,
966        };
967        assert!(
968            matches!(err, HttpError::DeadlineExceeded { after } if after == DEFAULT_RESPONSE_TIMEOUT),
969            "got {err}"
970        );
971        assert_eq!(inner.consumed(), 0, "no body was ever handed out");
972    }
973
974    #[pollster::test]
975    async fn a_declared_length_over_the_stream_cap_is_refused_before_a_chunk() {
976        let inner = StreamingClient::with_content_length(vec![b"tiny".to_vec()], "9000");
977        let mut request = streaming_request(None);
978        request.extensions_mut().insert(StreamPolicy {
979            idle_timeout: Duration::from_secs(5),
980            total_timeout: Duration::from_secs(60),
981            max_bytes: 1_024,
982        });
983        let err = match BoundedHttpClient::new(inner.clone(), Arc::new(NoTimerClock))
984            .send_streaming(request)
985            .await
986        {
987            Ok(response) => {
988                drop(response);
989                panic!("a declared 9 KB over a 1 KiB stream cap must be refused");
990            }
991            Err(err) => err,
992        };
993        assert!(
994            matches!(err, HttpError::ResponseTooLarge { limit: 1_024 }),
995            "got {err}"
996        );
997        assert_eq!(inner.consumed(), 0, "the body was never touched");
998    }
999
1000    #[pollster::test]
1001    async fn an_idle_gap_that_goes_quiet_abandons_the_body() {
1002        // The head gets through; the body's first poll outlives the idle
1003        // gap (the clock abandons it). One Err names the bound, then the
1004        // stream ends — and the inner body was dropped un-read, which is
1005        // the cancellation contract.
1006        let inner = StreamingClient::new(vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]);
1007        let client = BoundedHttpClient::new(
1008            inner.clone(),
1009            Arc::new(HeadThenAbandon(std::sync::atomic::AtomicUsize::new(0))),
1010        );
1011        let response = client
1012            .send_streaming(http::Request::new(Bytes::new()))
1013            .await
1014            .expect("the head answers within the buffered deadline");
1015        let items = drain(response.into_body()).await;
1016        assert_eq!(
1017            items.len(),
1018            1,
1019            "one terminal error, then the end: {items:?}"
1020        );
1021        assert!(
1022            matches!(items[0], Err(HttpError::DeadlineExceeded { after }) if after == DEFAULT_STREAM_IDLE_TIMEOUT),
1023            "the idle bound is what expired: {:?}",
1024            items[0]
1025        );
1026        assert_eq!(inner.consumed(), 0, "the abandoned poll dropped the body");
1027    }
1028
1029    #[pollster::test]
1030    async fn a_total_ceiling_ends_a_steady_stream() {
1031        // Thirty simulated seconds pass per body poll; the default
1032        // ten-minute ceiling stops the stream after its nineteenth chunk,
1033        // with the ceiling itself named as what expired.
1034        let chunks: Vec<Vec<u8>> = (0..40).map(|i| i.to_string().into_bytes()).collect();
1035        let inner = StreamingClient::new(chunks);
1036        let client = BoundedHttpClient::new(inner, AdvancingClock::new(30));
1037        let response = client
1038            .send_streaming(http::Request::new(Bytes::new()))
1039            .await
1040            .expect("the head answers");
1041        let items = drain(response.into_body()).await;
1042        assert_eq!(items.len(), 20, "19 chunks and one terminal error");
1043        for (index, item) in items.iter().enumerate().take(19) {
1044            let chunk = item.as_ref().expect("every steady chunk arrives");
1045            assert_eq!(&**chunk, index.to_string().as_bytes(), "chunk {index}");
1046        }
1047        assert!(
1048            matches!(items[19], Err(HttpError::DeadlineExceeded { after }) if after == DEFAULT_STREAM_TOTAL_TIMEOUT),
1049            "the whole-body ceiling is what expired: {:?}",
1050            items[19]
1051        );
1052    }
1053
1054    #[pollster::test]
1055    async fn a_body_that_ended_inside_its_own_time_ends_cleanly_at_the_ceiling() {
1056        // One chunk, then the end. Thirty simulated seconds pass per clock
1057        // read, the ceiling is sixty: the chunk is delivered with thirty
1058        // seconds left, and the *next* poll finds the ceiling expired — but
1059        // the body is over. The expiry poll is non-blocking, so the ready
1060        // `None` is a clean end: everything was delivered inside the body's
1061        // own time, and no DeadlineExceeded is invented after the fact.
1062        let inner = StreamingClient::new(vec![b"only".to_vec()]);
1063        let mut request = streaming_request(None);
1064        request.extensions_mut().insert(StreamPolicy {
1065            idle_timeout: Duration::from_secs(60),
1066            total_timeout: Duration::from_secs(60),
1067            max_bytes: MAX_STREAM_BYTES,
1068        });
1069        let client = BoundedHttpClient::new(inner, AdvancingClock::new(30));
1070        let response = client
1071            .send_streaming(request)
1072            .await
1073            .expect("the head answers");
1074        let items = drain(response.into_body()).await;
1075        assert_eq!(
1076            items.len(),
1077            1,
1078            "the chunk, then a clean end — no error after the data: {items:?}"
1079        );
1080        assert_eq!(&items[0].as_ref().expect("the chunk")[..], b"only");
1081    }
1082
1083    #[pollster::test]
1084    async fn a_chunk_already_in_hand_when_the_ceiling_expires_is_refused_not_delivered() {
1085        // Two chunks thirty simulated seconds apart under a sixty-second
1086        // ceiling. The first is delivered with thirty seconds left; by the
1087        // next poll the ceiling has run out, and the second chunk — already
1088        // in hand — would carry the body past it: refused, with the ceiling
1089        // named as what expired.
1090        let inner = StreamingClient::new(vec![b"first".to_vec(), b"second".to_vec()]);
1091        let mut request = streaming_request(None);
1092        request.extensions_mut().insert(StreamPolicy {
1093            idle_timeout: Duration::from_secs(60),
1094            total_timeout: Duration::from_secs(60),
1095            max_bytes: MAX_STREAM_BYTES,
1096        });
1097        let client = BoundedHttpClient::new(inner, AdvancingClock::new(30));
1098        let response = client
1099            .send_streaming(request)
1100            .await
1101            .expect("the head answers");
1102        let items = drain(response.into_body()).await;
1103        assert_eq!(
1104            items.len(),
1105            2,
1106            "the delivered chunk, the refusal: {items:?}"
1107        );
1108        assert_eq!(&items[0].as_ref().expect("the first chunk")[..], b"first");
1109        assert!(
1110            matches!(items[1], Err(HttpError::DeadlineExceeded { after }) if after == Duration::from_secs(60)),
1111            "the whole-body ceiling is what expired: {:?}",
1112            items[1]
1113        );
1114    }
1115
1116    #[pollster::test]
1117    async fn the_error_names_the_whole_body_ceiling_when_that_is_what_expired() {
1118        // The idle gap is five minutes, the ceiling sixty seconds: a poll
1119        // the clock abandons was up against what the ceiling still allowed
1120        // (sixty < three hundred), so the error carries the ceiling — the
1121        // idle test above pins the mirror case, where the idle gap is the
1122        // smaller and is the one named.
1123        let inner = StreamingClient::new(vec![b"a".to_vec(), b"b".to_vec()]);
1124        let mut request = streaming_request(None);
1125        request.extensions_mut().insert(StreamPolicy {
1126            idle_timeout: MAX_STREAM_IDLE_TIMEOUT,
1127            total_timeout: Duration::from_secs(60),
1128            max_bytes: MAX_STREAM_BYTES,
1129        });
1130        let client = BoundedHttpClient::new(
1131            inner,
1132            Arc::new(HeadThenAbandon(std::sync::atomic::AtomicUsize::new(0))),
1133        );
1134        let response = client
1135            .send_streaming(request)
1136            .await
1137            .expect("the head answers");
1138        let items = drain(response.into_body()).await;
1139        assert_eq!(
1140            items.len(),
1141            1,
1142            "one terminal error, then the end: {items:?}"
1143        );
1144        assert!(
1145            matches!(items[0], Err(HttpError::DeadlineExceeded { after }) if after == Duration::from_secs(60)),
1146            "the ceiling is what expired, not the idle gap: {:?}",
1147            items[0]
1148        );
1149    }
1150
1151    #[pollster::test]
1152    async fn a_steady_stream_outlives_the_buffered_deadline() {
1153        // Thirty-one simulated seconds per poll — each gap longer than the
1154        // whole-response cap a buffered send answers under — for forty
1155        // chunks: twenty minutes of body, delivered whole, because the
1156        // body is governed by the stream bounds, not `HttpPolicy`.
1157        let chunks: Vec<Vec<u8>> = (0..40).map(|i| i.to_string().into_bytes()).collect();
1158        let inner = StreamingClient::new(chunks);
1159        let mut request = streaming_request(None);
1160        request.extensions_mut().insert(StreamPolicy {
1161            idle_timeout: Duration::from_secs(60),
1162            total_timeout: MAX_STREAM_TOTAL_TIMEOUT,
1163            max_bytes: MAX_STREAM_BYTES,
1164        });
1165        let client = BoundedHttpClient::new(inner, AdvancingClock::new(31));
1166        let response = client
1167            .send_streaming(request)
1168            .await
1169            .expect("the head answers");
1170        let items = drain(response.into_body()).await;
1171        assert_eq!(
1172            items.len(),
1173            40,
1174            "forty chunks under a half-hour ceiling, then a clean end"
1175        );
1176        assert!(items.iter().all(Result::is_ok), "no bound fired: {items:?}");
1177        // The digits of 0..=39: ten one-digit numbers, thirty two-digit.
1178        assert_eq!(
1179            items
1180                .iter()
1181                .map(|item| item.as_ref().unwrap().len())
1182                .sum::<usize>(),
1183            10 + 60,
1184        );
1185    }
1186
1187    #[pollster::test]
1188    async fn a_chunk_over_the_stream_byte_cap_is_refused_not_delivered() {
1189        let inner = StreamingClient::new(vec![
1190            b"12345".to_vec(),
1191            b"67890".to_vec(),
1192            b"unreached".to_vec(),
1193        ]);
1194        let mut request = streaming_request(None);
1195        request.extensions_mut().insert(StreamPolicy {
1196            idle_timeout: Duration::from_secs(5),
1197            total_timeout: Duration::from_secs(60),
1198            max_bytes: 8,
1199        });
1200        let client = BoundedHttpClient::new(inner, Arc::new(NoTimerClock));
1201        let response = client
1202            .send_streaming(request)
1203            .await
1204            .expect("the head answers");
1205        let items = drain(response.into_body()).await;
1206        assert_eq!(items.len(), 2, "the fit chunk, the refusal, then the end");
1207        assert_eq!(&items[0].as_ref().unwrap()[..], &b"12345"[..]);
1208        assert!(
1209            matches!(items[1], Err(HttpError::ResponseTooLarge { limit: 8 })),
1210            "the crossing chunk is refused: {:?}",
1211            items[1]
1212        );
1213    }
1214}