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}