Skip to main content

cratefield_core/
http.rs

1//! HTTP plumbing every venture router shares (issue #2): the problem+json
2//! `Json` and `Form` extractors, the request-id middleware that creates
3//! the [`Scope`], and the `/v1/*` security headers.
4
5use axum::body::Body;
6use axum::extract::{FromRequest, Request};
7use axum::http::{HeaderValue, Request as HttpRequest, StatusCode, header};
8use axum::middleware::Next;
9use axum::response::{IntoResponse, Response as AxumResponse};
10use serde::de::DeserializeOwned;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::module::HARNESS_API;
15use crate::ports::{Decision, Defer, IdGen, Quota};
16use crate::problem::Problem;
17use crate::scope::Scope;
18use crate::sidecar::{X_HARNESS_API, X_HARNESS_MODULE};
19use crate::usage::Exhausted;
20use tracing::info_span;
21
22/// `x-request-id`: accepted from the client when it matches
23/// `^[A-Za-z0-9_-]{8,128}$`, otherwise generated as a ULID. Always set on
24/// the response (architecture section 6).
25pub const X_REQUEST_ID: &str = "x-request-id";
26
27/// Default request body limit for `/v1/*` JSON endpoints.
28pub const MAX_BODY_BYTES: usize = 64 * 1024;
29
30// `Duration::from_days` is unstable on the pinned toolchain
31// (`duration_constructors`), so this stays in seconds.
32#[allow(clippy::duration_suboptimal_units)]
33const CORS_PREFLIGHT_MAX_AGE: Duration = Duration::from_secs(86_400);
34
35/// The character class and length bounds of an accepted request id.
36pub fn request_id_is_valid(value: &str) -> bool {
37    (8..=128).contains(&value.len())
38        && value
39            .bytes()
40            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
41}
42
43/// The per-request span's fields as plain data, attached to every
44/// response's extensions by the request-id layer so a runtime with no
45/// tracing dispatcher — Cloudflare — can still log them. `route` is the
46/// matched pattern, never the raw path (`""` when nothing matched). There
47/// is no `duration_ms`: wasm32 has no monotonic clock to measure it with.
48#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
49pub struct RequestSummary {
50    pub request_id: String,
51    pub method: String,
52    pub route: String,
53    pub module: String,
54    pub status: u16,
55    pub ip_hash: String,
56    pub ua_family: String,
57}
58
59/// State the request-id layer needs, resolved from `Ports` when the router
60/// is assembled.
61#[derive(Clone)]
62pub(crate) struct ScopeState {
63    pub defer: Arc<dyn Defer>,
64    pub id_gen: Arc<dyn IdGen>,
65    /// The one module this deployment serves, when it serves exactly one —
66    /// the sidecar shape (ADR 0009). Set, the response carries
67    /// `x-harness-module` next to [`crate::sidecar::X_HARNESS_API`]; the
68    /// host reads both on every forwarded response, which is how a sidecar
69    /// redeployed against a different contract is caught within one
70    /// request instead of at a cold start that isolates do not have.
71    pub module: Option<&'static str>,
72}
73
74/// Middleware: resolve the request id, build the [`Scope`] (request id,
75/// defer, tracing span), insert it into extensions, echo the id on the
76/// response.
77pub(crate) async fn scope_layer(
78    axum::extract::State(state): axum::extract::State<ScopeState>,
79    mut request: Request,
80    next: Next,
81) -> AxumResponse {
82    use tracing::Instrument as _;
83    use tracing::field::Empty;
84
85    let incoming = request
86        .headers()
87        .get(X_REQUEST_ID)
88        .and_then(|value| value.to_str().ok())
89        .filter(|value| request_id_is_valid(value));
90    let request_id = match incoming {
91        Some(valid) => valid.to_owned(),
92        None => state.id_gen.ulid(),
93    };
94
95    // The one structured span per request (issue #14). `route`,
96    // `module`, `status` and `duration_ms` are recorded after the
97    // handler runs; no field ever carries an email (only `ip_hash`).
98    let method = request.method().as_str().to_owned();
99    let ip_hash = crate::logging::subject_hash(
100        &crate::rate_limit::client_ip(request.headers()).unwrap_or_default(),
101    );
102    let ua_family = request
103        .headers()
104        .get(header::USER_AGENT)
105        .and_then(|value| value.to_str().ok())
106        .map_or_else(|| "unknown".to_owned(), ua_family_of);
107    let span = info_span!(
108        "request",
109        request_id = %request_id,
110        method = %method,
111        route = Empty,
112        module = Empty,
113        status = Empty,
114        duration_ms = Empty,
115        ip_hash = %ip_hash,
116        ua_family = %ua_family,
117    );
118
119    let scope = Scope {
120        defer: Arc::clone(&state.defer),
121        span: span.clone(),
122        request_id: request_id.clone(),
123    };
124    request.extensions_mut().insert(scope);
125
126    let route = request
127        .extensions()
128        .get::<axum::extract::MatchedPath>()
129        .map(|matched| matched.as_str().to_owned())
130        .unwrap_or_default();
131
132    // `std::time::Instant::now()` panics on wasm32-unknown-unknown with
133    // "time not implemented on this platform", which took down every
134    // request on Workers — `/__health` included. There is no monotonic
135    // clock in that target, and `Date.now()` is frozen between I/O in
136    // workerd, so a wall-clock delta would read 0 and look measured.
137    // Timing is therefore recorded only where a real clock exists;
138    // Cloudflare's own request logs carry it on Workers.
139    #[cfg(not(target_arch = "wasm32"))]
140    let started = std::time::Instant::now();
141    let future = next.run(request);
142    let mut response = future.instrument(span.clone()).await;
143
144    if let Ok(value) = HeaderValue::from_str(&request_id) {
145        response.headers_mut().insert(X_REQUEST_ID, value);
146    }
147    // The identity stamp (issue #61): one insert, on every response, and
148    // never a cold-start subrequest. A sidecar is built and deployed
149    // separately, so the compile-time `Harness::build` contract check does
150    // not cover it; the host instead reads this header back on every
151    // forwarded response and refuses the prefix on a wrong number. That
152    // catches a sidecar redeployed under a warm host within one request —
153    // an isolate can outlive the sidecar for hours, so a cached cold-start
154    // verdict would keep serving a contract that no longer holds.
155    if let Ok(value) = HeaderValue::from_str(&HARNESS_API.to_string()) {
156        response.headers_mut().insert(X_HARNESS_API, value);
157    }
158    // The module name rides along so a host mounting several sidecars can
159    // tell whose answer it is looking at. It is only well-defined when the
160    // deployment serves exactly one module — the sidecar shape — so a
161    // multi-module host stays silent rather than stamping a guess.
162    if let Some(name) = state.module
163        && let Ok(value) = HeaderValue::from_str(name)
164    {
165        response.headers_mut().insert(X_HARNESS_MODULE, value);
166    }
167    let module = route
168        .strip_prefix("/v1/")
169        .and_then(|rest| rest.split('/').next())
170        .unwrap_or_default()
171        .to_owned();
172    let status = response.status().as_u16();
173    span.record("route", route.as_str());
174    span.record("module", module.as_str());
175    span.record("status", status);
176    #[cfg(not(target_arch = "wasm32"))]
177    {
178        let duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
179        span.record("duration_ms", duration_ms);
180    }
181    response.extensions_mut().insert(RequestSummary {
182        request_id,
183        method,
184        route,
185        module,
186        status,
187        ip_hash,
188        ua_family,
189    });
190    response
191}
192
193/// Coarse user-agent family: the first product token, lowercased —
194/// enough to group browsers, bots and libraries without a UA parser.
195fn ua_family_of(user_agent: &str) -> String {
196    let token = user_agent
197        .split(['/', ' ', ';', '('])
198        .next()
199        .unwrap_or_default()
200        .to_lowercase();
201    let truncated: String = token.chars().take(24).collect();
202    if truncated.is_empty() {
203        "unknown".to_owned()
204    } else {
205        truncated
206    }
207}
208
209/// Middleware: `/v1/*` responses carry
210/// `Cache-Control: no-store`, `X-Content-Type-Options: nosniff`,
211/// `Referrer-Policy: no-referrer` (or the handler's own `same-origin`,
212/// see `keeps_own_referrer_policy`), `X-Frame-Options: DENY` and
213/// `Content-Security-Policy: frame-ancestors 'none'` (architecture
214/// section 6). `/v1` is never meant to be framed: the API answers in
215/// JSON no page has business embedding, and the one document it serves —
216/// the magic-link confirmation — is HTML that must refuse to render
217/// inside a third party's frame, where a surrounding attacker page could
218/// clickjack the confirm button (issue #435).
219pub(crate) async fn security_headers_layer(request: Request, next: Next) -> AxumResponse {
220    let is_api = request.uri().path().starts_with("/v1/");
221    let mut response = next.run(request).await;
222    if is_api {
223        insert_no_store_headers(response.headers_mut());
224        insert_framing_headers(response.headers_mut());
225    }
226    response
227}
228
229/// Middleware for token-bearing URLs (issue #135). A `token` query
230/// parameter is the credential — confirmation links and
231/// `/ui/waitlist/status?token=…` authenticate with the URL itself, and
232/// `/ui/*` is outside the `/v1/*` rule above. Any response to a request
233/// whose query carries a `token` parameter therefore gets the same
234/// no-store headers at the root: no cache may keep the credential and no
235/// outbound navigation may leak it through `Referer`.
236pub(crate) async fn token_response_layer(request: Request, next: Next) -> AxumResponse {
237    let carries_token = request.uri().query().is_some_and(|query| {
238        query
239            .split('&')
240            .any(|pair| pair == "token" || pair.starts_with("token="))
241    });
242    let mut response = next.run(request).await;
243    if carries_token {
244        insert_no_store_headers(response.headers_mut());
245    }
246    response
247}
248
249fn insert_no_store_headers(headers: &mut axum::http::HeaderMap) {
250    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
251    headers.insert(
252        header::X_CONTENT_TYPE_OPTIONS,
253        HeaderValue::from_static("nosniff"),
254    );
255    if !keeps_own_referrer_policy(headers) {
256        headers.insert(
257            header::REFERRER_POLICY,
258            HeaderValue::from_static("no-referrer"),
259        );
260    }
261}
262
263/// Whether a handler's own `Referrer-Policy` survives the no-store
264/// stamp: only `same-origin`, which, like `no-referrer`, sends nothing
265/// to any other site (issue #840).
266///
267/// The one reason to want it is a page that posts a form back to
268/// itself. Under `no-referrer` a browser sends `Origin: null` on that
269/// POST, even same-origin, and a login-CSRF guard is right to refuse
270/// `null`; browsers that also send `Sec-Fetch-Site` get through on that,
271/// but webviews (Gmail's in-app browser among them) and older Safari send
272/// no fetch metadata, so their same-origin POST was refused. Under
273/// `same-origin` they send the real `Origin` instead. Anything else a
274/// handler set — `origin`, `unsafe-url`, a list — could carry a
275/// token-bearing URL to another site, so it is overwritten as before.
276fn keeps_own_referrer_policy(headers: &axum::http::HeaderMap) -> bool {
277    let mut values = headers.get_all(header::REFERRER_POLICY).iter();
278    match (values.next(), values.next()) {
279        (Some(value), None) => value
280            .to_str()
281            .is_ok_and(|policy| policy.trim().eq_ignore_ascii_case("same-origin")),
282        _ => false,
283    }
284}
285
286/// Frame-blocking headers, scoped to `/v1/*` only. Deliberately not
287/// folded into [`insert_no_store_headers`]: the token-bearing layer below
288/// shares that helper for *any* path whose query carries a `token` —
289/// including `/ui/*`, which sets its own `Content-Security-Policy` and
290/// `X-Frame-Options` (and deliberately none on `?fragment=1` fragments).
291/// Stamping the framing pair there would override the UI's answer; here
292/// it only ever lands on `/v1`, which has none of its own (issue #435).
293fn insert_framing_headers(headers: &mut axum::http::HeaderMap) {
294    // `insert`, not `append`: duplicated `X-Frame-Options` values are
295    // ill-defined and some browsers ignore the header entirely when it
296    // repeats, so the strictest value simply replaces whatever was there.
297    headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
298    // `append`, not `insert`: a browser handed several CSP policies
299    // enforces all of them — their intersection — so appending can only
300    // tighten whatever a `/v1` module set on its own response, where an
301    // `insert` would silently delete a fuller policy and substitute this
302    // one-directive one. No module sets a CSP under `/v1` today; this is
303    // written for the one that eventually does.
304    headers.append(
305        header::CONTENT_SECURITY_POLICY,
306        HeaderValue::from_static("frame-ancestors 'none'"),
307    );
308}
309
310/// A `Json` extractor and response whose rejections and serializations are
311/// problem+json (architecture section 6). Deserialization failures become a
312/// `400 validation-failed` problem listing the field.
313pub struct Json<T>(pub T);
314
315impl<T, S> FromRequest<S> for Json<T>
316where
317    T: DeserializeOwned,
318    S: Send + Sync,
319{
320    type Rejection = Problem;
321
322    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
323        let instance = request
324            .extensions()
325            .get::<Scope>()
326            .map(|scope| scope.request_id.clone());
327        match axum::Json::<T>::from_request(request, state).await {
328            Ok(axum::Json(value)) => Ok(Json(value)),
329            Err(rejection) => {
330                // Only a body that tripped the size limit is the shared 413
331                // slug. `BytesRejection::FailedToBufferBody` also covers a
332                // body that simply failed to arrive (a reset connection, a
333                // truncated chunked body), which axum itself rejects as a
334                // 400: that request's body never existed, so "too large"
335                // names a cause the caller never had. Everything else is a
336                // 400 validation problem.
337                let mut problem = match &rejection {
338                    axum::extract::rejection::JsonRejection::BytesRejection(rejection)
339                        if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE =>
340                    {
341                        Problem::request_too_large()
342                    }
343                    _ => Problem::validation_failed(rejection.body_text()),
344                };
345                if let Some(instance) = instance {
346                    problem = problem.instance(&instance);
347                }
348                Err(problem)
349            }
350        }
351    }
352}
353
354impl<T: serde::Serialize> IntoResponse for Json<T> {
355    fn into_response(self) -> AxumResponse {
356        axum::Json(self.0).into_response()
357    }
358}
359
360/// A form (`application/x-www-form-urlencoded`) extractor whose
361/// rejections are problem+json with the same shape as [`Json`]'s: body
362/// reads fail through the shared 413 slug (the size limit), everything
363/// else is a 400 validation problem. Needed for cross-site `form_post`
364/// callbacks (issue #46).
365pub struct Form<T>(pub T);
366
367impl<T, S> FromRequest<S> for Form<T>
368where
369    T: DeserializeOwned,
370    S: Send + Sync,
371{
372    type Rejection = Problem;
373
374    async fn from_request(request: HttpRequest<Body>, state: &S) -> Result<Self, Self::Rejection> {
375        let instance = request
376            .extensions()
377            .get::<Scope>()
378            .map(|scope| scope.request_id.clone());
379        match axum::Form::<T>::from_request(request, state).await {
380            Ok(axum::Form(value)) => Ok(Form(value)),
381            Err(rejection) => {
382                // Only a body that tripped the size limit is the shared 413
383                // slug; a body that failed to arrive is a 400, as in
384                // [`Json`]'s extractor above.
385                let mut problem = match &rejection {
386                    axum::extract::rejection::FormRejection::BytesRejection(rejection)
387                        if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE =>
388                    {
389                        Problem::request_too_large()
390                    }
391                    _ => Problem::validation_failed(rejection.body_text()),
392                };
393                if let Some(instance) = instance {
394                    problem = problem.instance(&instance);
395                }
396                Err(problem)
397            }
398        }
399    }
400}
401
402/// A `429 rate-limited` problem (architecture section 6). `Retry-After:
403/// <seconds>` comes from the [`Decision`]'s own pause, falling back to the
404/// quota's reset when the limiter reports a window but no pause; a known
405/// quota also rides the IETF draft `RateLimit-Limit`, `RateLimit-Remaining`
406/// and `RateLimit-Reset` headers (delta-seconds), so a well-behaved client
407/// can pace itself without probing (issue #538). A decision with no quota
408/// at all — a Workers Rate Limiting binding, or a failure resolved at the
409/// call site — is a bare `429` + `Retry-After`, exactly as before.
410pub fn rate_limited(decision: &Decision) -> AxumResponse {
411    let problem = Problem::new(&crate::problems::SLUGS.rate_limited);
412    let mut response = problem.into_response();
413    let pause = decision
414        .retry_after
415        .or_else(|| decision.quota.as_ref().map(|quota| quota.reset));
416    if let Some(pause) = pause
417        && let Ok(value) = HeaderValue::from_str(&delta_seconds(pause))
418    {
419        response.headers_mut().insert(header::RETRY_AFTER, value);
420    }
421    if let Some(quota) = decision.quota.as_ref() {
422        for (name, value) in quota_headers(quota) {
423            if let Ok(value) = HeaderValue::from_str(&value) {
424                response
425                    .headers_mut()
426                    .insert(header::HeaderName::from_static(name), value);
427            }
428        }
429    }
430    response
431}
432
433/// A `429 usage/allowance-exhausted` problem (issue #588): a metered
434/// allowance is spent for the current period. The body carries extension
435/// members a caller acts on — the `meter`, the `limit`, the `used` total and
436/// the `period_end` as RFC 3339 — and `Retry-After` is the delta-seconds to
437/// that reset, the same header [`rate_limited`] sets.
438pub fn allowance_exhausted(meter: &str, outcome: &Exhausted) -> AxumResponse {
439    let mut response = allowance_exhausted_problem(meter, outcome).into_response();
440    if let Ok(value) = HeaderValue::from_str(&delta_seconds(outcome.retry_after)) {
441        response.headers_mut().insert(header::RETRY_AFTER, value);
442    }
443    response
444}
445
446/// The problem behind [`allowance_exhausted`], without the header — built in
447/// one place so the response and the harness's re-render under the serving
448/// venture's base carry the same members.
449fn allowance_exhausted_problem(meter: &str, outcome: &Exhausted) -> Problem {
450    Problem::new(&crate::problems::SLUGS.allowance_exhausted)
451        .with_extension("meter", meter)
452        .with_extension("limit", outcome.limit)
453        .with_extension("used", outcome.used)
454        .with_extension("period_end", crate::usage::rfc3339(outcome.period_end))
455}
456
457/// The `RateLimit-*` headers for one quota, as `(name, value)` pairs with
458/// lowercase static names.
459fn quota_headers(quota: &Quota) -> [(&'static str, String); 3] {
460    [
461        ("ratelimit-limit", quota.limit.to_string()),
462        ("ratelimit-remaining", quota.remaining.to_string()),
463        ("ratelimit-reset", delta_seconds(quota.reset)),
464    ]
465}
466
467/// Delta-seconds for `Retry-After` and `RateLimit-Reset`: rounded up, so a
468/// client never waits a shorter time than the truth, and never zero.
469fn delta_seconds(pause: Duration) -> String {
470    let millis = u64::try_from(pause.as_millis()).unwrap_or(u64::MAX);
471    millis.div_ceil(1_000).max(1).to_string()
472}
473
474/// CORS allowlist from the venture's origins; never a wildcard
475/// (architecture section 6). Tower-http echoes the matched origin rather
476/// than emitting `*`, and requests from other origins get no CORS headers.
477///
478/// `PUT` and `Authorization` are on the list because a browser client on
479/// the venture's own site is a cross-origin caller: `cf.js` is served
480/// from the API origin but runs on the site's, and registering for
481/// notifications is `PUT /v1/notifications/subscriptions` with a bearer
482/// token (issue #183). Left off, the preflight fails and the fetch never
483/// leaves the browser — with a console message and no server-side trace
484/// at all.
485///
486/// Credentials stay off, which is what keeps that safe: no cookie is ever
487/// attached to a cross-origin request, so allowing the header only lets a
488/// script send a token it already holds and had to be given deliberately.
489pub(crate) fn cors_layer(origins: &[String]) -> tower_http::cors::CorsLayer {
490    use tower_http::cors::{AllowOrigin, CorsLayer};
491    let allowed: Vec<HeaderValue> = origins
492        .iter()
493        .filter_map(|origin| HeaderValue::from_str(origin).ok())
494        .collect();
495    CorsLayer::new()
496        .allow_origin(AllowOrigin::list(allowed))
497        .allow_methods([
498            axum::http::Method::GET,
499            axum::http::Method::POST,
500            axum::http::Method::PUT,
501            axum::http::Method::DELETE,
502            axum::http::Method::OPTIONS,
503        ])
504        .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
505        .max_age(CORS_PREFLIGHT_MAX_AGE)
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use axum::http::StatusCode;
512
513    fn header(response: &AxumResponse, name: &str) -> Option<String> {
514        response
515            .headers()
516            .get(name)
517            .and_then(|value| value.to_str().ok().map(str::to_owned))
518    }
519
520    /// Issue #840: a page that posts back to itself may answer with
521    /// `same-origin`, which leaks nothing cross-site and lets the browser
522    /// send a real `Origin`; every other policy a handler sets is still
523    /// replaced by `no-referrer`.
524    #[test]
525    fn only_a_handlers_same_origin_policy_survives_the_no_store_stamp() {
526        let stamped = |policy: Option<&'static str>| {
527            let mut headers = axum::http::HeaderMap::new();
528            if let Some(policy) = policy {
529                headers.insert(header::REFERRER_POLICY, HeaderValue::from_static(policy));
530            }
531            insert_no_store_headers(&mut headers);
532            let values: Vec<_> = headers
533                .get_all(header::REFERRER_POLICY)
534                .iter()
535                .map(|value| value.to_str().unwrap_or_default().to_owned())
536                .collect();
537            values.join(",")
538        };
539        assert_eq!(stamped(None), "no-referrer");
540        assert_eq!(stamped(Some("same-origin")), "same-origin");
541        assert_eq!(stamped(Some("Same-Origin")), "Same-Origin");
542        for weaker in [
543            "unsafe-url",
544            "origin",
545            "strict-origin-when-cross-origin",
546            "no-referrer-when-downgrade",
547            "same-origin, unsafe-url",
548        ] {
549            assert_eq!(stamped(Some(weaker)), "no-referrer", "{weaker} survived");
550        }
551        // Two headers are ambiguous; the strict value wins.
552        let mut headers = axum::http::HeaderMap::new();
553        headers.append(
554            header::REFERRER_POLICY,
555            HeaderValue::from_static("same-origin"),
556        );
557        headers.append(
558            header::REFERRER_POLICY,
559            HeaderValue::from_static("unsafe-url"),
560        );
561        insert_no_store_headers(&mut headers);
562        assert_eq!(headers.get_all(header::REFERRER_POLICY).iter().count(), 1);
563        assert_eq!(headers.get(header::REFERRER_POLICY).unwrap(), "no-referrer");
564    }
565
566    #[test]
567    fn a_pause_becomes_retry_after_and_nothing_else() {
568        let response = rate_limited(&Decision {
569            ok: false,
570            retry_after: Some(Duration::from_secs(7)),
571            quota: None,
572        });
573        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
574        assert_eq!(header(&response, "retry-after").as_deref(), Some("7"));
575        assert!(header(&response, "ratelimit-limit").is_none());
576    }
577
578    #[test]
579    fn a_quota_rides_the_ratelimit_headers() {
580        let response = rate_limited(&Decision {
581            ok: false,
582            retry_after: None,
583            quota: Some(Quota {
584                limit: 30,
585                remaining: 0,
586                reset: Duration::from_secs(43),
587            }),
588        });
589        assert_eq!(header(&response, "retry-after").as_deref(), Some("43"));
590        assert_eq!(header(&response, "ratelimit-limit").as_deref(), Some("30"));
591        assert_eq!(
592            header(&response, "ratelimit-remaining").as_deref(),
593            Some("0")
594        );
595        assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("43"));
596    }
597
598    #[test]
599    fn the_quotas_reset_backfills_a_missing_retry_after() {
600        let response = rate_limited(&Decision {
601            ok: false,
602            retry_after: None,
603            quota: Some(Quota {
604                limit: 5,
605                remaining: 2,
606                reset: Duration::from_millis(1500),
607            }),
608        });
609        // Delta-seconds round up: a client never waits less than the truth.
610        assert_eq!(header(&response, "retry-after").as_deref(), Some("2"));
611        assert_eq!(header(&response, "ratelimit-reset").as_deref(), Some("2"));
612    }
613
614    #[test]
615    fn an_answerless_decision_is_a_bare_429() {
616        let response = rate_limited(&Decision {
617            ok: false,
618            retry_after: None,
619            quota: None,
620        });
621        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
622        assert!(header(&response, "retry-after").is_none());
623        assert!(header(&response, "ratelimit-limit").is_none());
624    }
625
626    fn exhausted() -> Exhausted {
627        Exhausted {
628            used: 100,
629            limit: 100,
630            period_end: time::OffsetDateTime::from_unix_timestamp(1_800_000_000).expect("in range"),
631            retry_after: Duration::from_secs(120),
632        }
633    }
634
635    #[test]
636    fn an_exhausted_allowance_is_a_429_with_a_retry_after() {
637        let response = allowance_exhausted("mail_sent", &exhausted());
638        assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
639        assert_eq!(header(&response, "retry-after").as_deref(), Some("120"));
640    }
641
642    /// A request whose body never arrives — a reset connection, a truncated
643    /// chunked body — is not an oversize body. `axum` rejects it as a 400
644    /// (`BytesRejection::FailedToBufferBody` holds both a length-limit and an
645    /// unknown-body error, and only the former is a 413), and
646    /// `docs/ERRORS.md` scopes `request-too-large` to "the request body
647    /// exceeded the 64 KiB limit". Telling the caller its body was too large
648    /// when the body never existed is a wrong status for a wrong reason.
649    struct BodyThatNeverArrives;
650
651    impl futures_core::Stream for BodyThatNeverArrives {
652        type Item = Result<bytes::Bytes, std::io::Error>;
653
654        fn poll_next(
655            self: std::pin::Pin<&mut Self>,
656            _: &mut std::task::Context<'_>,
657        ) -> std::task::Poll<Option<Self::Item>> {
658            std::task::Poll::Ready(Some(Err(std::io::Error::new(
659                std::io::ErrorKind::ConnectionReset,
660                "connection reset by peer",
661            ))))
662        }
663    }
664
665    fn request_with_a_dead_body(content_type: &'static str) -> Request {
666        Request::builder()
667            .method("POST")
668            .uri("/v1/thing")
669            .header(header::CONTENT_TYPE, content_type)
670            .body(Body::from_stream(BodyThatNeverArrives))
671            .expect("request builds")
672    }
673
674    #[pollster::test]
675    async fn a_body_that_never_arrives_is_a_400_not_a_413() {
676        let Err(problem) = Json::<serde_json::Value>::from_request(
677            request_with_a_dead_body("application/json"),
678            &(),
679        )
680        .await
681        else {
682            panic!("the body never arrives");
683        };
684        assert_eq!(
685            problem.status,
686            StatusCode::BAD_REQUEST,
687            "a failed body read is not request-too-large: {}",
688            problem.slug
689        );
690        assert_eq!(problem.slug, crate::problems::SLUGS.validation_failed.slug);
691    }
692
693    /// The same shape through the form extractor, which shares the mapping.
694    #[pollster::test]
695    async fn a_form_body_that_never_arrives_is_a_400_not_a_413() {
696        let Err(problem) = Form::<std::collections::HashMap<String, String>>::from_request(
697            request_with_a_dead_body("application/x-www-form-urlencoded"),
698            &(),
699        )
700        .await
701        else {
702            panic!("the body never arrives");
703        };
704        assert_eq!(
705            problem.status,
706            StatusCode::BAD_REQUEST,
707            "a failed body read is not request-too-large: {}",
708            problem.slug
709        );
710    }
711
712    #[test]
713    fn an_exhausted_allowance_carries_its_meter_limit_used_and_period_end() {
714        // Rendered under a venture base, the way the harness's outermost
715        // layer renders every problem: the type URI and the extension
716        // members must both survive (issue #588).
717        let body = allowance_exhausted_problem("mail_sent", &exhausted())
718            .body("https://api.test.example/problems/");
719        assert_eq!(
720            body["type"],
721            "https://api.test.example/problems/usage/allowance-exhausted"
722        );
723        assert_eq!(body["meter"], "mail_sent");
724        assert_eq!(body["limit"], 100);
725        assert_eq!(body["used"], 100);
726        assert_eq!(body["period_end"], "2027-01-15T08:00:00Z");
727        assert_eq!(body["status"], 429);
728    }
729}