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}