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}