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