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}