Skip to main content

cratefield_runtime_cloudflare/
lib.rs

1//! `cratefield-runtime-cloudflare` runs a Factory Zero [`Harness`] on
2//! Cloudflare Workers (ADR 0001, 0002). It maps bindings to ports:
3//! D1 -> `Database`, KV -> `KeyValue`, the Rate Limiting binding ->
4//! `RateLimiter`, `HARNESS_SECRET` -> `Signer`, `Context::wait_until` ->
5//! `Defer`, `worker::Fetch` -> `HttpClient`.
6//!
7//! A venture's Worker is three lines:
8//!
9//! ```ignore
10//! #[event(fetch)]
11//! pub async fn fetch(req: HttpRequest, env: Env, ctx: Context)
12//!     -> Result<http::Response<axum::body::Body>> {
13//!     let (harness, runtime) = INSTANCE.get_or_init(build);
14//!     serve(harness, runtime, req, env, ctx).await
15//! }
16//! ```
17//!
18//! Every port adapter here holds JS handles that workers-rs already marks
19//! `Send + Sync` (`unsafe impl` inside the `worker` crate, sound because a
20//! Workers isolate is single-threaded — ADR 0002). This crate itself
21//! contains no `unsafe`.
22
23#![doc = include_str!("../README.md")]
24#![forbid(unsafe_code)]
25
26mod body_limit;
27mod config;
28mod ports;
29mod runtime;
30mod tracing_setup;
31
32pub use config::EnvConfig;
33pub use ports::{
34    ActorDriver, ContextDefer, D1Database, D1RateLimiter, FetchClient, KvStorePort, Limit,
35    RATE_LIMIT_COUNTERS_SQL, RateLimitPolicy, RateLimitPort, RoomDriver, ScheduleDefer,
36    WorkersClock, client_ip,
37};
38pub use runtime::Cloudflare;
39pub use tracing_setup::install_tracing;
40
41use crate::body_limit::{BodyPlan, Capped, body_plan, read_capped};
42use crate::runtime::{WARNED_UNRESOLVED_LIMITER, warn_once};
43use cratefield_core::{
44    Harness, Problem, RequestStream, RequestSummary, ResponseStream, SLUGS, ScheduledLimits,
45    ScheduledSplit,
46};
47use futures_core::Stream;
48use futures_util::StreamExt;
49use std::pin::Pin;
50use std::sync::Arc;
51use std::task::{Context as TaskContext, Poll};
52use time::OffsetDateTime;
53use tower::ServiceExt;
54use worker::send::SendWrapper;
55use worker::{Context, Env, Request as WorkerRequest, Response as WorkerResponse};
56
57/// Runs every module's [`Module::validate_config`] against the live config
58/// once per isolate and logs any failure to `console_error!`. `validate_config`
59/// cannot run at build or in `fz doctor` (neither has the deploy config; it
60/// lives on the `Env`), so cold start is the first place it can, and this
61/// makes a misconfigured deployment loud in Workers Logs (issue #101). It only
62/// logs — a bad module still degrades per request rather than failing the whole
63/// Worker's boot; that harder behaviour is a decision for an ADR.
64fn check_module_config_once(harness: &Harness, config: &dyn cratefield_core::Config) {
65    use std::sync::atomic::{AtomicBool, Ordering};
66    static CHECKED: AtomicBool = AtomicBool::new(false);
67    if CHECKED.swap(true, Ordering::Relaxed) {
68        return;
69    }
70    for module in harness.modules() {
71        if let Err(err) = module.validate_config(config) {
72            worker::console_error!(
73                "[config] module `{}` has invalid configuration: {err}",
74                module.name()
75            );
76        }
77    }
78}
79
80/// Logs the push wiring report once per isolate, next to
81/// [`check_module_config_once`] (issue #191). Names and verdicts only —
82/// [`PushWiring::summary`](cratefield_push_wiring::PushWiring::summary) never
83/// carries a value, so no secret can reach Workers Logs through it.
84///
85/// A half-wired transport is `console_error!` in production and
86/// `console_log!` below it: a venture that meant to enable FCM and mistyped
87/// one variable must not boot into a state where every Android send silently
88/// answers `NotConfigured`, while a developer wiring a transport one variable
89/// at a time must still be able to boot. Like the module-config check this
90/// only logs; refusing to serve is a decision for an ADR, and `fz doctor` is
91/// what refuses a deploy.
92///
93/// Nothing is logged when the venture passed its own adapter: `push_wiring`
94/// answers `None`, so no environment is read and no transport the venture
95/// deliberately overrode is reported on. It runs after `ports()`, which has
96/// already assembled and memoised the adapters, so this reads a report
97/// rather than building one.
98#[cfg(feature = "push")]
99fn check_push_wiring_once(
100    harness: &Harness,
101    runtime: &Cloudflare,
102    env: &Env,
103    config: &dyn cratefield_core::Config,
104) {
105    use cratefield_push_wiring::WiringSeverity;
106    use std::sync::atomic::{AtomicBool, Ordering};
107    static CHECKED: AtomicBool = AtomicBool::new(false);
108    if CHECKED.swap(true, Ordering::Relaxed) {
109        return;
110    }
111    let Some(wiring) = runtime.push_wiring(env) else {
112        return;
113    };
114    worker::console_log!("[push] {}", wiring.summary());
115    let deployed = cratefield_core::deployed_env(harness.venture().env, config);
116    match wiring.severity(deployed) {
117        WiringSeverity::Ok => {}
118        WiringSeverity::Warning => {
119            for problem in wiring.problems() {
120                worker::console_log!("[push] warning: {problem}");
121            }
122        }
123        WiringSeverity::Error => {
124            for problem in wiring.problems() {
125                worker::console_error!("[push] {problem}");
126            }
127        }
128    }
129}
130
131/// Serves one fetch event: resolves ports from the bindings, builds the
132/// router, refuses an oversize body before it is resident, hands the
133/// request over, and converts the response (issue #440).
134///
135/// Takes the native `worker::Request` (the fetch macro's `FromRequest`
136/// accepts it): `Request::bytes()` is the only body read that reliably
137/// resolves under workerd/miniflare — streaming a `worker::Body` through
138/// the axum bridge or re-wrapping it into `web_sys::Request` hangs the
139/// isolate (verified empirically). `bytes()` therefore remains the read for
140/// every body that declares a `content-length` within the ceiling.
141///
142/// Before any byte is read, the request's per-module body ceiling is looked
143/// up from the path ([`Harness::max_body_bytes`]) — the coarse value a
144/// module may raise (LinkedIn's image upload), never lower. A declared
145/// `content-length` above it is answered `413` (`request-too-large`)
146/// without reading the body: an isolate has a fixed memory ceiling, and the
147/// router's `DefaultBodyLimit` only fires once the body is already
148/// resident, which is too late for a Worker that has already died. A body
149/// with no usable `content-length` — chunked, streamed, or a header that
150/// does not parse — is read through `Request::stream()` (a worker-native
151/// stream, not the hanging axum bridge) and aborted the moment it passes
152/// the ceiling, so nothing larger than the ceiling is ever held. A request
153/// with no body at all — most GETs, HEAD, OPTIONS — reaches the router as
154/// an empty buffer without any read: worker 0.8.5's `Request::stream()`
155/// sets `body_used` before discovering there is no body, so the bodyless
156/// case is detected through `inner()` first (issue #440). Inside the
157/// router, `DefaultBodyLimit` stays the precise per-route enforcer.
158///
159/// A route the owning module declared in
160/// [`Module::streaming_routes`](cratefield_core::Module::streaming_routes)
161/// is never buffered (issue #585): the runtime consults
162/// [`Harness::streaming_route`] for the route's own ceiling, and — for a
163/// body within it — breaks the request into a
164/// [`RequestStream`] and hands the router an
165/// empty axum body. The core streaming request layer sees that handle in the
166/// extensions and passes the request through untouched, so a handler reads
167/// the body chunk by chunk instead of resident. A declared `content-length`
168/// over the route ceiling is refused unread, exactly as the buffered path
169/// refuses one over the module's. On the way out, a handler that answered
170/// with a [`ResponseStream`] is bridged to
171/// the wire through `worker::Response::from_stream`, never buffered.
172///
173/// # Errors
174///
175/// `worker::Error` on conversion/transport failures; problem+json
176/// responses are ordinary 4xx/5xx Worker responses.
177pub async fn serve(
178    harness: &Harness,
179    runtime: &Cloudflare,
180    mut req: WorkerRequest,
181    env: Env,
182    ctx: Context,
183) -> worker::Result<WorkerResponse> {
184    install_tracing();
185    let ports = runtime.ports(&env, Arc::new(ContextDefer(ctx)));
186    check_module_config_once(harness, ports.config.as_ref());
187    #[cfg(feature = "push")]
188    check_push_wiring_once(harness, runtime, &env, ports.config.as_ref());
189    // `ports` moves into `router()` below, and the ceiling lookup reads the
190    // same config the module contexts were built with — clone the `Arc`
191    // out first.
192    let config = Arc::clone(&ports.config);
193    let url = req.url()?;
194    // The short-circuits below — the unresolved-limiter `503` and the
195    // `413` — answer before the router runs, so no layer stands behind
196    // them to name the venture (issue #557): name it here, the same way
197    // the router's outermost layer would.
198    let problem_type_base = harness.venture().problem_type_base();
199    // Fail closed when the composition named a limiter binding that did
200    // not resolve (issue #562): those routes were written to be throttled,
201    // and a missing binding used to degrade to serving them unlimited with
202    // only a logged warning. `/v1/*` only, like the readiness guard — the
203    // probes and the UI stay up to say why — and in every environment:
204    // unlimited is a decision the composition makes, never one a missing
205    // binding makes for it. `ports.rate_limiter` stays `None`, so
206    // `production_readiness` still reads this as "not readiness".
207    if let Some(detail) = runtime.unresolved_limiter_refusal(ports.rate_limiter.is_some())
208        && url.path().starts_with("/v1/")
209    {
210        // Once per isolate, not once per refused request (the readiness
211        // guard's record discipline, issue #441).
212        warn_once(&WARNED_UNRESOLVED_LIMITER, &detail);
213        // This short-circuits before the router, so the 503 carries no
214        // CORS headers and an OPTIONS preflight is refused like any other
215        // request — accepted: a Worker that cannot throttle is down.
216        return response_to_worker(unresolved_limiter_response(detail, &problem_type_base)).await;
217    }
218    let router = harness.router(ports);
219
220    // The method is needed before the body is read: it decides whether the
221    // route streams (issue #585).
222    let method = http::Method::from_bytes(req.method().to_string().as_bytes())
223        .map_err(|err| worker::Error::RustError(err.to_string()))?;
224    // A streaming route's own ceiling replaces the module's coarse buffered
225    // one — the precise per-route enforcer, not a pre-buffer guard.
226    let streaming = harness.streaming_route(url.path(), &method);
227    let limit = streaming.unwrap_or_else(|| harness.max_body_bytes(url.path(), config.as_ref()));
228    // HEAD never carries a response body: axum serves it through the GET
229    // handler with an emptied body, so the streamed path must not bridge the
230    // (still present) `ResponseStream` handle to the wire.
231    let is_head = method == http::Method::HEAD;
232
233    // Copied out so the header borrow ends before the body is read mutably.
234    // `worker::Headers::get` already yields `Option<String>`; a lookup
235    // failure is treated as no declaration at all, which earns the capped
236    // stream rather than trust.
237    let declared = req.headers().get("content-length").ok().flatten();
238
239    // When set, a `RequestStream` for a declared streaming route: the router
240    // gets an empty buffered body and this handle in its extensions, and the
241    // core streaming layer passes the request through untouched (issue #585).
242    let mut request_stream: Option<RequestStream> = None;
243
244    let bytes = match body_plan(declared.as_deref(), limit) {
245        BodyPlan::Refuse => {
246            // The tail left unread here trips `wrangler dev`'s drain
247            // middleware — a dev-only artefact, recorded in this README's
248            // wasm notes.
249            return response_to_worker(
250                Problem::request_too_large().into_response_with_base(&problem_type_base),
251            )
252            .await;
253        }
254        // A streaming route: never buffer, whatever the declared length —
255        // a `content-length` at or under the ceiling earns the same
256        // streaming read a chunked body does. Probe `inner().body()` first,
257        // exactly as the buffered path below does and for the same reason
258        // (worker 0.8.5's `stream()` poisons a bodyless request).
259        BodyPlan::Stream | BodyPlan::Buffer if streaming.is_some() => {
260            request_stream = Some(match req.inner().body() {
261                None => RequestStream::empty(),
262                Some(_) => match req.stream() {
263                    Ok(stream) => RequestStream::new(send_stream(stream), limit),
264                    // Unreachable past the probe, as below: answer empty
265                    // rather than swallow a genuine transport error.
266                    Err(_) => RequestStream::empty(),
267                },
268            });
269            Vec::new()
270        }
271        BodyPlan::Buffer => req.bytes().await?,
272        BodyPlan::Stream => {
273            // A bodyless request (most GETs, HEAD, OPTIONS) must reach the
274            // router as an empty body, and worker 0.8.5 makes that a
275            // non-obvious requirement: `Request::stream()` sets
276            // `body_used = true` *before* it looks for a body
277            // (request.rs:199-204), so a null-body request answers
278            // `Err("no body for request")` having already poisoned the
279            // request — and `bytes()` afterwards can only answer
280            // `Error::BodyUsed` (request.rs:158-173). The old
281            // `Err(_) => req.bytes()` fallback therefore failed every
282            // bodyless request out of `serve()` — `/__health`, `/__ready`,
283            // `/ui/*`, `/.well-known/jwks.json` (issue #440). Probe
284            // non-destructively through `inner()`, the raw
285            // `web_sys::Request`, whose `body()` answers `None` exactly
286            // where `stream()` would fail: with no body, hand the router
287            // the empty buffer the Fetch spec gives a null body and never
288            // touch `stream()` at all.
289            match req.inner().body() {
290                None => Vec::new(),
291                Some(_) => match req.stream() {
292                    Ok(stream) => match read_capped(stream, limit).await {
293                        Ok(Capped::Within(bytes)) => bytes,
294                        Ok(Capped::TooLarge) => {
295                            return response_to_worker(
296                                Problem::request_too_large()
297                                    .into_response_with_base(&problem_type_base),
298                            )
299                            .await;
300                        }
301                        Err(err) => return Err(err),
302                    },
303                    // Belt and braces: past the probe, `stream()`'s only
304                    // remaining failure mode is the `body_used` poison, and
305                    // `bytes()` reads that same flag — so no read can
306                    // succeed here. The arm answers empty, not silently:
307                    // it is unreachable while the probe and `stream()` read
308                    // the same `body()` getter (a body the probe saw cannot
309                    // disappear), so a genuine transport error is not being
310                    // swallowed, and a 500 on a body the getter had just
311                    // reported would be strictly worse.
312                    Err(_) => Vec::new(),
313                },
314            }
315        }
316    };
317
318    let mut builder = http::Request::builder().method(method).uri(url.to_string());
319    {
320        let headers = req.headers();
321        for (name, value) in headers {
322            builder = builder.header(name.as_str(), value.as_str());
323        }
324    }
325    let mut buffered = builder
326        .body(axum::body::Body::from(bytes))
327        .map_err(|err| worker::Error::RustError(err.to_string()))?;
328    if let Some(stream) = request_stream {
329        // The core streaming layer reads this and leaves the empty body
330        // above alone (issue #585).
331        buffered.extensions_mut().insert(stream);
332    }
333
334    let mut response = router
335        .oneshot(buffered)
336        .await
337        .map_err(|err| worker::Error::RustError(err.to_string()))?;
338    if is_head {
339        // Drop the handle: axum has already emptied the body for HEAD, and
340        // the buffered path below reads that empty body, keeping status and
341        // headers — taking the stream instead would send the whole body a
342        // HEAD must not carry.
343        response.extensions_mut().remove::<ResponseStream>();
344    }
345    if let Some(line) = response.extensions().get().and_then(summary_line) {
346        // The request span is inert on Workers (no dispatcher, see
347        // `tracing_setup`), so its fields reach Workers Logs as one JSON
348        // line instead — indexed as fields, and matched by
349        // `wrangler tail --search <request-id>`.
350        #[cfg(target_arch = "wasm32")]
351        worker::console_log!("{line}");
352        #[cfg(not(target_arch = "wasm32"))]
353        tracing::info!("{line}");
354    }
355    response_to_worker(response).await
356}
357
358/// The Workers Logs line for one request: the core [`RequestSummary`]
359/// serialized as JSON.
360fn summary_line(summary: &RequestSummary) -> Option<String> {
361    serde_json::to_string(summary).ok()
362}
363
364async fn response_to_worker(
365    response: http::Response<axum::body::Body>,
366) -> worker::Result<WorkerResponse> {
367    let (parts, body) = response.into_parts();
368
369    // A streamed response (issue #585): the handler answered with a
370    // `ResponseStream`, whose `IntoResponse` left a lazy axum body *and* the
371    // handle in the extensions. Polling that axum body on wasm is the hang
372    // this path exists to avoid, so take the stream and bridge it to a
373    // Worker response directly. Whoever takes first wins; if the body
374    // already consumed it (`take` answers `None`) fall through to the
375    // buffered read, which then sees the empty remainder.
376    if let Some(stream) = parts.extensions.get::<ResponseStream>()
377        && let Some(inner) = stream.take()
378    {
379        let mapped =
380            inner.map(|chunk| chunk.map_err(|err| worker::Error::RustError(err.to_string())));
381        let mut out = WorkerResponse::from_stream(mapped)?.with_status(parts.status.as_u16());
382        copy_headers(out.headers_mut(), &parts.headers);
383        return Ok(out);
384    }
385
386    let bytes = axum::body::to_bytes(body, MAX_RESPONSE_BUFFER)
387        .await
388        .map_err(|err| worker::Error::RustError(err.to_string()))?;
389    let mut out =
390        WorkerResponse::from_bytes(bytes.as_ref().to_vec())?.with_status(parts.status.as_u16());
391    copy_headers(out.headers_mut(), &parts.headers);
392    Ok(out)
393}
394
395/// Copies every response header onto the Worker response, per name: drop what
396/// the `worker` builder pre-set (a default `content-type:
397/// application/octet-stream` from `from_bytes`), then `append` every value.
398/// `set` kept only the last value of a multi-valued header (`Vary` from the
399/// CORS layer plus the handler's own, later `Set-Cookie`; found by
400/// `GET /__surface` losing `Vary: Authorization`, issue #70), and a bare
401/// `append` stacked onto the pre-set default (found by `/ui` pages answering
402/// `content-type: application/octet-stream, text/html`, issue #72). Shared by
403/// the buffered and streamed paths so both carry the same headers.
404fn copy_headers(worker_headers: &mut worker::Headers, headers: &http::HeaderMap) {
405    for name in headers.keys() {
406        let _ = worker_headers.delete(name.as_str());
407        for value in headers.get_all(name) {
408            let _ = worker_headers.append(name.as_str(), value.to_str().unwrap_or_default());
409        }
410    }
411}
412
413/// The boxed worker body stream [`SendStream`] holds.
414type BoxedWorkerBody = Pin<Box<dyn Stream<Item = Result<Vec<u8>, worker::Error>>>>;
415
416/// A `worker::ByteStream` is `!Send` — its `JsFuture` holds an `Rc` — but a
417/// Workers isolate is single-threaded (ADR 0002), which is exactly what
418/// `worker::send::SendWrapper` is for: the `worker` crate's own safe (mis)claim
419/// that a JS-backed type may cross a `Send` bound. Coercing the stream to a
420/// boxed trait object and wrapping that costs no `unsafe` of our own — the
421/// crate still `forbid`s it — and gives [`RequestStream::new`], which needs
422/// `Send + 'static`, a stream to hold.
423struct SendStream(SendWrapper<BoxedWorkerBody>);
424
425impl Stream for SendStream {
426    type Item = Result<Vec<u8>, worker::Error>;
427
428    fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<Self::Item>> {
429        // `Pin<Box<_>>` is `Unpin`, so the whole newtype is and `get_mut` is
430        // sound; the boxed stream stays pinned.
431        self.get_mut().0.0.as_mut().poll_next(cx)
432    }
433}
434
435/// [`SendStream`]s a worker body stream. `Box::new` before `Pin::from` so the
436/// unsizing coercion to the trait object is the well-worn one.
437fn send_stream<S>(stream: S) -> SendStream
438where
439    S: Stream<Item = Result<Vec<u8>, worker::Error>> + 'static,
440{
441    let boxed: Box<dyn Stream<Item = Result<Vec<u8>, worker::Error>>> = Box::new(stream);
442    SendStream(SendWrapper::new(Pin::from(boxed)))
443}
444
445/// Responses are JSON (small); 1 MiB is a generous ceiling.
446const MAX_RESPONSE_BUFFER: usize = 1024 * 1024;
447
448/// The conservative limits a scheduled invocation runs under when the
449/// venture does not choose its own: 25 seconds of wall time and 40
450/// subrequest-shaped steps. Chosen to fit the Workers Free plan's 50
451/// subrequests per invocation with headroom, not taken from the plan: it
452/// is a default, not a platform fact, and a venture on a paid plan passes
453/// its own limits to [`serve_scheduled_with_limits`].
454pub const CLOUDFLARE_SCHEDULED_LIMITS: ScheduledLimits = ScheduledLimits {
455    wall: Some(time::Duration::seconds(25)),
456    subrequests: Some(40),
457};
458
459/// Fans a scheduled event out to every module's `scheduled(ctx, cron)`
460/// under [`CLOUDFLARE_SCHEDULED_LIMITS`]. Handler errors are logged and
461/// never fail the cron.
462pub async fn serve_scheduled(
463    harness: &Harness,
464    runtime: &Cloudflare,
465    event: worker::ScheduledEvent,
466    env: Env,
467    ctx: worker::ScheduleContext,
468) {
469    serve_scheduled_with_limits(
470        harness,
471        runtime,
472        event,
473        env,
474        ctx,
475        CLOUDFLARE_SCHEDULED_LIMITS,
476    )
477    .await;
478}
479
480/// [`serve_scheduled`] with the invocation's limits named explicitly
481/// (issue #537): the whole run may spend `limits.wall` of wall time and
482/// `limits.subrequests` subrequest-shaped steps, split across the modules
483/// in order with each module's unspent share rolling forward to the ones
484/// after it. A module's share arrives as `ModuleContext::scheduled`, which
485/// the module — and `Outbox::drain_within` — checks cooperatively before
486/// each unit of work; the runtime never cancels a module that ignores it.
487/// A module that runs its share out is named in Workers Logs, and a
488/// handler error is still only logged, never a failed cron.
489pub async fn serve_scheduled_with_limits(
490    harness: &Harness,
491    runtime: &Cloudflare,
492    event: worker::ScheduledEvent,
493    env: Env,
494    ctx: worker::ScheduleContext,
495    limits: ScheduledLimits,
496) {
497    install_tracing();
498    let cron = event.cron();
499    let ports = runtime.ports(&env, Arc::new(ScheduleDefer(ctx)));
500    check_module_config_once(harness, ports.config.as_ref());
501    #[cfg(feature = "push")]
502    check_push_wiring_once(harness, runtime, &env, ports.config.as_ref());
503    // `ports` always wires WorkersClock, so the first arm is the real one.
504    // A ports set without a clock cannot check a wall limit at all, so it
505    // runs the unbounded split — which reads no clock and computes no
506    // deadline, so the epoch placeholders below are never observed.
507    let clock = ports.clock.clone();
508    let mut split = match clock.as_ref() {
509        Some(clock) => ScheduledSplit::new(limits, clock.now(), harness.modules().len()),
510        None => ScheduledSplit::new(
511            ScheduledLimits::UNBOUNDED,
512            OffsetDateTime::UNIX_EPOCH,
513            harness.modules().len(),
514        ),
515    };
516    for module in harness.modules() {
517        let now = clock
518            .as_ref()
519            .map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
520        let budget = Arc::new(split.next(now));
521        let mut module_ctx = harness.module_context(module.as_ref(), &ports);
522        module_ctx.scheduled = Arc::clone(&budget);
523        if let Err(err) = module.scheduled(&module_ctx, &cron).await {
524            tracing::error!(
525                module = module.name(),
526                cron = %cron,
527                error = %err,
528                "scheduled module work failed",
529            );
530        }
531        // Judged on a fresh reading: a module that overran spent wall time
532        // this invocation does not have to wait for the next one to be named.
533        let after = clock
534            .as_ref()
535            .map_or(OffsetDateTime::UNIX_EPOCH, |clock| clock.now());
536        if budget.exhausted(after) {
537            tracing::warn!(
538                module = module.name(),
539                cron = %cron,
540                spent = budget.spent(),
541                "scheduled module work exhausted its budget",
542            );
543        }
544        split.settle(&budget);
545    }
546}
547
548/// The unresolved-limiter refusal (issue #562), named under the serving
549/// venture's problem base (issue #557): it short-circuits before the
550/// router, whose outermost layer would otherwise have named it.
551fn unresolved_limiter_response(
552    detail: String,
553    problem_type_base: &str,
554) -> axum::response::Response {
555    Problem::new(&SLUGS.not_production_ready)
556        .with_detail(detail)
557        .into_response_with_base(problem_type_base)
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    async fn body_json(response: axum::response::Response) -> serde_json::Value {
565        let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
566            .await
567            .expect("the body reads");
568        serde_json::from_slice(&bytes).expect("the body is JSON")
569    }
570
571    #[test]
572    fn unresolved_limiter_refusal_names_the_serving_venture() {
573        // Issues #557 and #562 meet here: the fail-closed 503 answers
574        // before the router, so it must name the venture itself.
575        let venture = cratefield_core::Venture::new("acme", "acme.example")
576            .public_url("https://acme.example");
577        let response = unresolved_limiter_response(
578            "limiter binding RATE_LIMITER did not resolve".to_owned(),
579            &venture.problem_type_base(),
580        );
581        assert_eq!(response.status(), 503);
582        assert_eq!(
583            response.headers()[axum::http::header::CONTENT_TYPE],
584            "application/problem+json"
585        );
586        let body = pollster::block_on(body_json(response));
587        assert_eq!(
588            body["type"],
589            "https://acme.example/problems/not-production-ready"
590        );
591        assert!(
592            !body.to_string().contains("factory0.ventures"),
593            "no other venture's domain: {body}"
594        );
595    }
596
597    #[test]
598    fn unresolved_limiter_refusal_without_public_url_is_about_blank() {
599        // `Venture::new` defaults the public URL to the domain; clear it.
600        let venture = cratefield_core::Venture::new("acme", "acme.example").public_url("");
601        let response = unresolved_limiter_response(
602            "limiter binding RATE_LIMITER did not resolve".to_owned(),
603            &venture.problem_type_base(),
604        );
605        assert_eq!(
606            pollster::block_on(body_json(response))["type"],
607            cratefield_core::ABOUT_BLANK
608        );
609    }
610
611    #[test]
612    fn summary_line_is_one_json_object_carrying_the_request_id() {
613        let summary = RequestSummary {
614            request_id: "01J9ZQ4V6X8Y2K3M5N7P9R1T3W".to_owned(),
615            method: "GET".to_owned(),
616            route: "/v1/sample/hello".to_owned(),
617            module: "sample".to_owned(),
618            status: 200,
619            ip_hash: "0123456789ab".to_owned(),
620            ua_family: "mozilla".to_owned(),
621        };
622        let line = summary_line(&summary).expect("serializes");
623        assert!(!line.contains('\n'), "one line: {line}");
624        let parsed: serde_json::Value = serde_json::from_str(&line).expect("valid JSON");
625        assert_eq!(parsed["request_id"], "01J9ZQ4V6X8Y2K3M5N7P9R1T3W");
626        assert_eq!(parsed["route"], "/v1/sample/hello");
627        assert_eq!(parsed["status"], 200);
628    }
629}