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`].
17//! 2. **Deadline.** Every send is bounded by the effective
18//!    [`HttpPolicy::timeout`]; an upstream that has not answered by then
19//!    is abandoned (not awaited) with [`HttpError::DeadlineExceeded`], so
20//!    a hung service cannot pin a Worker request or a native tenant. The
21//!    native runtime enforces this inside `reqwest` (and thereby against
22//!    each redirect hop); the Workers runtime cannot time `worker::Fetch`
23//!    itself, so it is enforced through the [`Clock`](super::Clock) port by
24//!    [`BoundedHttpClient`], which every runtime wires around its
25//!    implementation.
26//! 3. **Destinations.** An implementation that opens real sockets MUST
27//!    refuse destinations a caller-supplied URL must never reach — non
28//!    `http(s)` schemes, userinfo, and loopback, private, link-local,
29//!    multicast or cloud-metadata addresses (169.254.169.254 and
30//!    friends, in IPv6 and decimal/octal/hex encodings alike) — with
31//!    [`HttpError::BlockedDestination`], and MUST re-vet **every redirect
32//!    hop** rather than trusting the first destination. On Workers this
33//!    falls to the platform, whose `fetch` refuses non-public
34//!    destinations; the native runtime implements the vetting itself.
35//! 4. **Concurrency budget.** A runtime with a shared outbound client MUST
36//!    cap in-flight requests at [`MAX_CONCURRENT_REQUESTS`] per process
37//!    (one native process serves one tenant, so that cap *is* the
38//!    per-tenant budget) and refuse past it rather than queue unbounded
39//!    work.
40//!
41//! Bounds are per-operation and travel as a request extension
42//! ([`HttpPolicy::of_request`]); the extensions never reach the wire,
43//! implementations read them before rebuilding the transport request.
44
45use std::sync::Arc;
46use std::time::Duration;
47
48use async_trait::async_trait;
49use bytes::Bytes;
50use thiserror::Error;
51
52use super::Clock;
53use crate::ports::timeout;
54
55/// Hard ceiling on a response body this port may return: an adapter's
56/// largest legitimate upstream reply (JWKS documents, Stripe objects,
57/// LinkedIn `people` payloads) is orders of magnitude below it, and it is
58/// far below anything that threatens isolate memory. Callers may lower it
59/// per operation, never raise it.
60pub const MAX_RESPONSE_BYTES: usize = 4 * 1024 * 1024;
61
62/// Deadline applied to an outbound call that asks for none. Longer than
63/// the strictest in-tree self-bound (Turnstile's 5s verify), shorter than
64/// anything that could pin a request.
65pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);
66
67/// Ceiling on a caller-supplied deadline. A 30 s Worker request cannot
68/// responsibly spend more than half of it waiting on one upstream.
69pub const MAX_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
70
71/// Per-process budget for concurrent outbound requests. The native
72/// runtime shares one connection pool across the tenant it serves; this
73/// is what stops one request's fan-out (say a cron fanning out emails)
74/// from monopolising the pool and the response-buffer memory behind it.
75pub const MAX_CONCURRENT_REQUESTS: usize = 32;
76
77/// Per-operation outbound bounds, attached to a request as an extension
78/// and resolved by every implementation with [`HttpPolicy::of_request`].
79/// Both fields clamp to the port maxima: a caller may only tighten.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub struct HttpPolicy {
82    /// Maximum accepted response body size.
83    pub max_response_bytes: usize,
84    /// Maximum time to wait for the upstream to answer.
85    pub timeout: Duration,
86}
87
88impl Default for HttpPolicy {
89    fn default() -> Self {
90        Self {
91            max_response_bytes: MAX_RESPONSE_BYTES,
92            timeout: DEFAULT_RESPONSE_TIMEOUT,
93        }
94    }
95}
96
97impl HttpPolicy {
98    /// The effective policy for `request`: the attached [`HttpPolicy`]
99    /// extension if any, clamped to the port ceilings.
100    #[must_use]
101    pub fn of_request<T>(request: &http::Request<T>) -> Self {
102        request
103            .extensions()
104            .get::<HttpPolicy>()
105            .copied()
106            .unwrap_or_default()
107            .clamped()
108    }
109
110    /// Clamps to [`MAX_RESPONSE_BYTES`] / [`MAX_RESPONSE_TIMEOUT`]:
111    /// callers may lower a bound, never raise it.
112    #[must_use]
113    pub fn clamped(self) -> Self {
114        Self {
115            max_response_bytes: self.max_response_bytes.min(MAX_RESPONSE_BYTES),
116            timeout: self.timeout.min(MAX_RESPONSE_TIMEOUT),
117        }
118    }
119}
120
121/// The declared `Content-Length` of a response, when it parses as a
122/// non-negative size. Implementations check this before touching the
123/// body, so an oversized declared length is refused without allocating
124/// for it (issue #136).
125#[must_use]
126pub fn declared_content_length(headers: &http::HeaderMap) -> Option<usize> {
127    headers
128        .get(http::header::CONTENT_LENGTH)
129        .and_then(|value| value.to_str().ok())
130        .and_then(|value| value.parse::<usize>().ok())
131}
132
133#[derive(Debug, Clone, Error)]
134pub enum HttpError {
135    #[error("http request failed: {0}")]
136    Transport(String),
137    /// The upstream's response body exceeded the [`HttpPolicy`] cap.
138    #[error("upstream response exceeds the {limit}-byte bound")]
139    ResponseTooLarge {
140        /// The effective cap that was exceeded.
141        limit: usize,
142    },
143    /// The upstream did not answer within the [`HttpPolicy`] deadline and
144    /// was abandoned.
145    #[error("upstream deadline of {after:?} exceeded")]
146    DeadlineExceeded {
147        /// The effective deadline.
148        after: Duration,
149    },
150    /// The destination (or one of its redirect hops) is refused by the
151    /// outbound policy: scheme, userinfo, or a loopback / private /
152    /// link-local / metadata address.
153    #[error("destination refused by outbound policy: {0}")]
154    BlockedDestination(String),
155}
156
157#[async_trait]
158pub trait HttpClient: Send + Sync {
159    /// Sends `request` and returns the fully-buffered response under the
160    /// bounds this port carries: the body is at most
161    /// [`MAX_RESPONSE_BYTES`] unless an [`HttpPolicy`] tightens it, the
162    /// exchange is bounded by that policy's deadline
163    /// (enforced through [`BoundedHttpClient`] on runtimes that wrap),
164    /// the destination is vetted where real sockets are opened, and a
165    /// runtime that keeps a concurrency budget holds one for the send.
166    async fn send(&self, request: http::Request<Bytes>)
167    -> Result<http::Response<Bytes>, HttpError>;
168}
169
170/// Wraps any [`HttpClient`] so the port's bounds hold on every runtime:
171/// the deadline is enforced through the [`Clock`](super::Clock) port (the
172/// only timer core is allowed to use), and the response is refused — by
173/// declared length before anything else, then by actual size — when it
174/// exceeds the [`HttpPolicy`] cap.
175///
176/// This is defence in depth, not the only defence: the native adapter
177/// also enforces the cap while streaming (before the oversized body is
178/// ever buffered) and the Workers adapter checks the declared length
179/// before calling `bytes()`. Runtimes wire every `ports.http` through
180/// this wrapper.
181pub struct BoundedHttpClient {
182    inner: Arc<dyn HttpClient>,
183    clock: Arc<dyn Clock>,
184}
185
186impl BoundedHttpClient {
187    /// Bounds `inner`'s sends by `clock`-measured deadlines and caps.
188    #[must_use]
189    pub fn new(inner: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Self {
190        Self { inner, clock }
191    }
192}
193
194#[async_trait]
195impl HttpClient for BoundedHttpClient {
196    async fn send(
197        &self,
198        request: http::Request<Bytes>,
199    ) -> Result<http::Response<Bytes>, HttpError> {
200        let policy = HttpPolicy::of_request(&request);
201        let inner = Arc::clone(&self.inner);
202        let Some(outcome) = timeout(
203            self.clock.as_ref(),
204            async move { inner.send(request).await },
205            policy.timeout,
206        )
207        .await
208        else {
209            return Err(HttpError::DeadlineExceeded {
210                after: policy.timeout,
211            });
212        };
213        let response = outcome?;
214        if declared_content_length(response.headers())
215            .is_some_and(|len| len > policy.max_response_bytes)
216        {
217            return Err(HttpError::ResponseTooLarge {
218                limit: policy.max_response_bytes,
219            });
220        }
221        if response.body().len() > policy.max_response_bytes {
222            return Err(HttpError::ResponseTooLarge {
223                limit: policy.max_response_bytes,
224            });
225        }
226        Ok(response)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use http::Response;
234
235    /// Policy resolution is the port's whole vocabulary; pin the clamps.
236    #[test]
237    fn a_policy_clamps_to_the_port_ceilings() {
238        let loose = HttpPolicy {
239            max_response_bytes: usize::MAX,
240            timeout: Duration::from_secs(3600),
241        };
242        let clamped = loose.clamped();
243        assert_eq!(clamped.max_response_bytes, MAX_RESPONSE_BYTES);
244        assert_eq!(clamped.timeout, MAX_RESPONSE_TIMEOUT);
245
246        let tight = HttpPolicy {
247            max_response_bytes: 1024,
248            timeout: Duration::from_millis(50),
249        };
250        assert_eq!(tight.clamped(), tight, "tightening is honoured");
251
252        let defaulted = HttpPolicy::of_request(&http::Request::new(Bytes::new()));
253        assert_eq!(defaulted, HttpPolicy::default());
254        let mut request = http::Request::new(Bytes::new());
255        request.extensions_mut().insert(HttpPolicy {
256            max_response_bytes: usize::MAX,
257            timeout: Duration::from_secs(3600),
258        });
259        let resolved = HttpPolicy::of_request(&request);
260        assert_eq!(resolved.max_response_bytes, MAX_RESPONSE_BYTES);
261        assert_eq!(resolved.timeout, MAX_RESPONSE_TIMEOUT);
262    }
263
264    /// The inner client is never asked to police itself: a body over the
265    /// cap, by declared length or by actual size, is refused here.
266    struct ScriptedClient {
267        response: Result<Response<Bytes>, HttpError>,
268    }
269
270    #[async_trait]
271    impl HttpClient for ScriptedClient {
272        async fn send(&self, _request: http::Request<Bytes>) -> Result<Response<Bytes>, HttpError> {
273            self.response.clone()
274        }
275    }
276
277    struct NoTimerClock;
278
279    #[async_trait]
280    impl Clock for NoTimerClock {
281        fn now(&self) -> time::OffsetDateTime {
282            time::OffsetDateTime::UNIX_EPOCH
283        }
284        // default timeout_any: runs to completion — the wrapper's own
285        // checks must then catch the violation.
286    }
287
288    struct AlwaysTimeoutClock;
289
290    #[async_trait]
291    impl Clock for AlwaysTimeoutClock {
292        fn now(&self) -> time::OffsetDateTime {
293            time::OffsetDateTime::UNIX_EPOCH
294        }
295        async fn timeout_any(
296            &self,
297            fut: futures_core::future::BoxFuture<'static, Box<dyn std::any::Any + Send>>,
298            _after: Duration,
299        ) -> Option<Box<dyn std::any::Any + Send>> {
300            drop(fut);
301            None
302        }
303    }
304
305    fn bounded(inner: ScriptedClient, clock: Arc<dyn Clock>) -> BoundedHttpClient {
306        BoundedHttpClient::new(Arc::new(inner), clock)
307    }
308
309    #[pollster::test]
310    async fn an_oversized_response_body_is_refused() {
311        let inner = ScriptedClient {
312            response: Ok(Response::new(Bytes::from(vec![0_u8; 4_096]))),
313        };
314        let mut request = http::Request::new(Bytes::new());
315        request.extensions_mut().insert(HttpPolicy {
316            max_response_bytes: 1_024,
317            timeout: Duration::from_secs(5),
318        });
319        let err = bounded(inner, Arc::new(NoTimerClock))
320            .send(request)
321            .await
322            .expect_err("4 KiB over a 1 KiB cap must be refused");
323        assert!(
324            matches!(err, HttpError::ResponseTooLarge { limit: 1_024 }),
325            "got {err}"
326        );
327    }
328
329    #[pollster::test]
330    async fn an_oversized_declared_length_is_refused_before_the_body_counts() {
331        // Actual body is small, but the declared length lies about a huge
332        // response; the wrapper refuses on the header alone.
333        let inner = ScriptedClient {
334            response: Ok(Response::builder()
335                .header(http::header::CONTENT_LENGTH, "9000")
336                .body(Bytes::from_static(b"hi"))
337                .expect("test response")),
338        };
339        let mut request = http::Request::new(Bytes::new());
340        request.extensions_mut().insert(HttpPolicy {
341            max_response_bytes: 1_024,
342            timeout: Duration::from_secs(5),
343        });
344        let err = bounded(inner, Arc::new(NoTimerClock))
345            .send(request)
346            .await
347            .expect_err("a declared 9 KB over a 1 KiB cap must be refused");
348        assert!(
349            matches!(err, HttpError::ResponseTooLarge { .. }),
350            "got {err}"
351        );
352    }
353
354    #[pollster::test]
355    async fn an_abandoned_send_is_a_deadline_not_a_hang() {
356        // The clock gives up on the future (its contract: abandon, do not
357        // await); the wrapper maps that to DeadlineExceeded with the
358        // effective duration, even though the inner would have answered.
359        let inner = ScriptedClient {
360            response: Ok(Response::new(Bytes::from_static(b"late"))),
361        };
362        let err = bounded(inner, Arc::new(AlwaysTimeoutClock))
363            .send(http::Request::new(Bytes::new()))
364            .await
365            .expect_err("an abandoned send must surface as the deadline");
366        assert!(
367            matches!(&err, HttpError::DeadlineExceeded { after } if *after == DEFAULT_RESPONSE_TIMEOUT),
368            "got {err}"
369        );
370    }
371}