Skip to main content

siderite_cache/
route.rs

1//! [`RouteCache`]: HTTP GET/HEAD response cache as an `App` layer.
2
3use crate::{Cache, CacheError};
4use http::header::PROXY_AUTHORIZATION;
5use http::header::{ACCEPT, ACCEPT_ENCODING, AUTHORIZATION};
6use http::header::{CACHE_CONTROL, COOKIE};
7use http::header::{HOST, SET_COOKIE, VARY};
8use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header};
9use siderite_core::middleware::TrustedProxies;
10use siderite_core::middleware::{BoxService, Next, from_fn};
11use siderite_core::{ApiError, Body, IntoResponse, Request, Response};
12use std::sync::Arc;
13use std::sync::atomic::{AtomicU8, Ordering};
14use std::time::Duration;
15use tower::Layer;
16
17mod codec;
18mod generation;
19use codec::{decode, encode};
20use generation::representation_key as make_key;
21type CacheResult<T> = Result<T, CacheError>;
22type Directive = (String, Option<String>);
23type Hit = Option<(HeaderMap, Vec<u8>)>;
24
25const X_CACHE: HeaderName = HeaderName::from_static("x-cache");
26const HIT: HeaderValue = HeaderValue::from_static("hit");
27const MISS: HeaderValue = HeaderValue::from_static("miss");
28
29/// Largest body stored by default (1 MiB).
30pub const DEFAULT_MAX_BODY_BYTES: u64 = 1024 * 1024;
31
32/// Default lifetime of a target's generation marker (one hour).
33pub const DEFAULT_GENERATION_TTL: Duration = Duration::from_secs(3600);
34
35/// Default admission budget: new markers per namespace per
36/// `generation_ttl` window.
37pub const DEFAULT_MAX_NEW_TARGETS: u64 = 10_000;
38
39/// Shared flag: a failed invalidation disabled the layer.
40const DISABLED: u8 = 1;
41/// Shared flag: no expiring generation markers (unsupported backend or a
42/// zero `generation_ttl`), so the layer neither stores nor invalidates.
43const NO_MARKERS: u8 = 2;
44/// Shared flag: a marker admission failure was already logged.
45const ADMIT_WARNED: u8 = 4;
46
47/// Request headers that are part of the cache key; a response may `Vary` on
48/// these and still be stored.
49const KEYED_HEADERS: [HeaderName; 2] = [ACCEPT, ACCEPT_ENCODING];
50
51/// Hop-by-hop headers (RFC 9110) plus the marker this layer writes.
52fn is_hop_by_hop(name: &HeaderName) -> bool {
53    matches!(
54        name.as_str(),
55        "connection"
56            | "keep-alive"
57            | "proxy-authenticate"
58            | "proxy-authorization"
59            | "te"
60            | "trailer"
61            | "trailers"
62            | "transfer-encoding"
63            | "upgrade"
64            | "x-cache"
65    )
66}
67
68/// Middleware that caches successful GET and HEAD responses.
69///
70/// Caching is **opt-in**. There are two ways to opt a route in:
71///
72/// * **Per response.** Put the layer on the whole app; it stores only
73///   responses marked `Cache-Control: public` (for example with
74///   [`Cached::public`](siderite_core::Cached)). Everything else passes
75///   through.
76/// * **Per route.** Put the layer on one route with `MethodRouter::layer` and
77///   set [`default_ttl`](RouteCache::default_ttl); that route's responses are
78///   stored without a `public` marker.
79///
80/// ```
81/// use siderite_cache::{MemoryCache, RouteCache};
82/// use siderite_core::{App, Cached, get};
83/// use std::time::Duration;
84///
85/// let cache = MemoryCache::new(1024);
86/// let _app = App::new()
87///     // Opted in by its response.
88///     .route("/news", get(|| async {
89///         Cached::public(Duration::from_secs(60), "news")
90///     }))
91///     // Opted in by placement.
92///     .route("/stats", get(|| async { "stats" }).layer(
93///         RouteCache::new(cache.clone())
94///             .default_ttl(Duration::from_secs(30)),
95///     ))
96///     // Never stored: no `public` marker.
97///     .route("/me", get(|| async { "private" }))
98///     .layer(RouteCache::new(cache));
99/// ```
100///
101/// Do not set `default_ttl` on an app-wide layer unless every GET route in
102/// the app is safe to share between users.
103///
104/// # Behaviour
105/// * Only GET and HEAD responses are stored. Successful unsafe target
106///   writes rotate a shared generation across every representation.
107///   Related URI dependencies require explicit application invalidation.
108/// * The cache key is the method, scheme, `Host`, path and query (as
109///   received), plus the request's `Accept` and `Accept-Encoding` values, so
110///   virtual hosts sharing one cache never see each other's responses.
111/// * Requests carrying credentials bypass the cache: `Authorization`,
112///   `Proxy-Authorization`, `Cookie`, `X-API-Key`, any header whose name
113///   contains `auth`, `token`, `session`, `jwt`, `secret`, `api-key`,
114///   `apikey` or `access-key`, and any header added with
115///   [`RouteCache::bypass_header`]. Such requests are neither
116///   answered from nor stored in the cache.
117/// * Responses are stored only when the status is 200, there is no
118///   `Set-Cookie`, `Cache-Control` does not contain `no-store`, `no-cache` or
119///   `private`, `Vary` names no header outside the key, and the body length
120///   is known and at most [`RouteCache::max_body_bytes`]. Streaming bodies
121///   (server-sent events, file streams) pass through untouched.
122/// * On top of that, a response needs `Cache-Control: public` unless
123///   [`default_ttl`](RouteCache::default_ttl) is set.
124/// * The entry lives for `s-maxage`, else `max-age`, else `default_ttl`. A
125///   `public` response without any of these, or with a lifetime of zero, is
126///   not stored.
127/// * Request no-cache refreshes; no-store bypasses without deleting older
128///   entries. Unsupported conditionals and ranges bypass. Date is retained
129///   and Age includes origin age and residence. Entries are versioned.
130/// * Namespaces isolate layers by default. Stable namespaces intentionally
131///   share generations; key, header and encoded byte admission is bounded.
132/// * Every GET/HEAD response is tagged with `x-cache: hit` or `x-cache: miss`.
133///
134/// Cache backend failures fail open: the request is served and treated as a
135/// miss. Failed write invalidation disables this layer. A database commit
136/// and distributed cache invalidation are separate operations; other nodes
137/// need an outage/restart consistency policy. No secret values are logged.
138#[derive(Clone, Debug)]
139pub struct RouteCache<C> {
140    cache: Arc<C>,
141    default_ttl: Option<Duration>,
142    max_body: u64,
143    bypass: Arc<Vec<HeaderName>>,
144    keys: Arc<generation::Keys>,
145    local: Arc<generation::Local>,
146    proxies: TrustedProxies,
147    flags: Arc<AtomicU8>,
148    max_key: usize,
149    max_headers: usize,
150    max_encoded: usize,
151    generation_ttl: Duration,
152    max_new_targets: u64,
153}
154
155impl<C: Cache> RouteCache<C> {
156    /// Cache GET/HEAD 200 responses marked `Cache-Control: public` in
157    /// `cache`.
158    #[must_use]
159    pub fn new(cache: C) -> Self {
160        Self {
161            cache: Arc::new(cache),
162            default_ttl: None,
163            max_body: DEFAULT_MAX_BODY_BYTES,
164            keys: Arc::new(generation::Keys::new(&uuid::Uuid::new_v4().to_string())),
165            local: Arc::new(generation::Local::new()),
166            proxies: TrustedProxies::default(),
167            flags: Arc::new(AtomicU8::new(0)),
168            max_key: 4096,
169            max_headers: 8192,
170            max_encoded: 2 * 1024 * 1024,
171            generation_ttl: DEFAULT_GENERATION_TTL,
172            max_new_targets: DEFAULT_MAX_NEW_TARGETS,
173            bypass: Arc::new(vec![
174                AUTHORIZATION,
175                PROXY_AUTHORIZATION,
176                COOKIE,
177                HeaderName::from_static("x-api-key"),
178            ]),
179        }
180    }
181
182    /// Set a stable app/deployment namespace for intentionally shared caches.
183    ///
184    /// Args:
185    ///     namespace: Identity distinct from incompatible apps or revisions.
186    ///
187    /// Returns:
188    ///     Configured layer; the default namespace is unique per instance.
189    #[must_use]
190    pub fn namespace(mut self, namespace: impl Into<String>) -> Self {
191        self.keys = Arc::new(generation::Keys::new(&namespace.into()));
192        self
193    }
194
195    /// Resolve cache request scheme with the shared trusted-peer policy.
196    ///
197    /// Args:
198    ///     proxies: Same immediate-peer policy used by other middleware.
199    ///
200    /// Returns:
201    ///     Layer ignoring forwarded headers from untrusted peers.
202    #[must_use]
203    pub fn trusted_proxies(mut self, proxies: TrustedProxies) -> Self {
204        self.proxies = proxies;
205        self
206    }
207
208    /// Bound keys, stored header bytes and serialized response bytes.
209    ///
210    /// Args:
211    ///     key: Maximum key bytes, including namespace and generation.
212    ///     hdrs: Maximum stored header-name/value bytes.
213    ///     encoded: Maximum versioned serialized response size.
214    ///
215    /// Returns:
216    ///     Layer bypassing oversized entries without failing the origin.
217    #[must_use]
218    pub fn byte_limits(self, key: usize, hdrs: usize, encoded: usize) -> Self {
219        let mut settings = self;
220        settings.max_key = key;
221        settings.max_headers = hdrs;
222        settings.max_encoded = encoded;
223        settings
224    }
225
226    /// Set the lifetime of per-target generation markers.
227    ///
228    /// Markers are created with this expiry (default
229    /// [`DEFAULT_GENERATION_TTL`], one hour) and stored representations
230    /// never outlive it: a representation's TTL is clamped to
231    /// `min(response ttl, generation_ttl)`. When a marker expires, the next
232    /// request draws a fresh random generation, so earlier or in-flight
233    /// fills become unreachable and read as safe misses.
234    ///
235    /// Args:
236    ///     ttl: Marker lifetime. Zero means route caching is disabled:
237    ///         no request touches the backend, nothing is stored or
238    ///         invalidated, and responses carry `x-cache: miss`. A value
239    ///         above the backend's portable limit is rejected by the
240    ///         backend, so every request bypasses the cache.
241    ///
242    /// Note: `max-age`/`s-maxage` above `ttl` is capped to `ttl` (default
243    /// one hour), as is `default_ttl`.
244    ///
245    /// Returns:
246    ///     Configured layer. A custom [`Cache`] without
247    ///     `set_if_absent_with_ttl` makes the layer bypass storage.
248    #[must_use]
249    pub fn generation_ttl(mut self, ttl: Duration) -> Self {
250        self.generation_ttl = ttl;
251        if ttl.is_zero() {
252            self.flags.fetch_or(NO_MARKERS, Ordering::Release);
253        } else {
254            self.flags.fetch_and(!NO_MARKERS, Ordering::Release);
255        }
256        self
257    }
258
259    /// Bound the metadata one namespace can create (default
260    /// [`DEFAULT_MAX_NEW_TARGETS`]).
261    ///
262    /// At most `max` new target markers are admitted per namespace in each
263    /// `generation_ttl` window, counted when a representation is first
264    /// stored for a target, and counted exactly: a marker that would exceed
265    /// the budget is never visible. Once the window's budget is spent,
266    /// further new targets bypass the cache (`x-cache: miss`) until the
267    /// next window; targets that already have a marker keep hitting, and
268    /// the layer remembers the spent window locally, so those requests
269    /// cost one backend read.
270    ///
271    /// Windows are `floor(clock / generation_ttl)`. `RedisCache` reads
272    /// the Redis server clock inside the admission script (and expires the
273    /// counter on that same clock after two windows), so every process
274    /// sharing a namespace uses one window whatever its own clock says;
275    /// `MemoryCache` uses its own clock. A marker lives one
276    /// `generation_ttl`, so live markers were created in at most two
277    /// consecutive windows: at most `2 * max` per namespace. A stored
278    /// representation can outlive its marker's creation window by up to
279    /// another `generation_ttl`, so representations come from at most three
280    /// windows: at most `3 * max * v` per namespace, where `v` is the
281    /// number of request variants (method, `Accept`, `Accept-Encoding`)
282    /// seen per target. Fills still in the origin when their marker ages
283    /// out add at most one representation each.
284    ///
285    /// Zero admits nothing: GET and HEAD requests make no backend calls.
286    /// Successful writes still invalidate, which is one backend call each,
287    /// because other processes sharing the namespace may have markers.
288    ///
289    /// The counter lives in the backend, so the bound holds across
290    /// processes sharing a namespace, provided the backend does not evict
291    /// the counter. Third-party [`Cache`] adapters that do not override
292    /// [`Cache::admit_marker`] use the generic sequence, whose window comes
293    /// from each process's own clock: processes whose clocks differ by a
294    /// large fraction of `generation_ttl` can then each spend a full
295    /// budget, up to the number of distinct clock phases times the bound
296    /// above. A [`Cache`] without `increment_with_expiry` or
297    /// `set_if_absent_with_ttl` cannot enforce the budget, so the layer
298    /// stops storing (one warning).
299    #[must_use]
300    pub fn max_new_targets(mut self, max: u64) -> Self {
301        self.max_new_targets = max;
302        self
303    }
304
305    /// Invalidate every cached representation of one related target URI.
306    ///
307    /// Args:
308    ///     req: Request identifying the target authority, scheme and URI.
309    ///
310    /// Returns:
311    ///     Success after rotating its shared backend generation. Use this
312    ///     for application-specific list/detail dependencies after writes.
313    ///
314    /// # Errors
315    /// Invalid context, oversized key or cache backend failure. A failed
316    /// invalidation disables this layer to avoid stale local reuse.
317    pub async fn invalidate_target(&self, req: &Request) -> CacheResult<()> {
318        let policy = self.policy();
319        let failure = |_| CacheError::Backend("invalid context".into());
320        let context = self.proxies.resolve(req).map_err(failure)?;
321        let target = generation::target(req, &policy, context.scheme);
322        if target.len() > self.max_key {
323            return Err(CacheError::Backend("cache key too large".into()));
324        }
325        let backend = self.cache.as_ref();
326        let result = generation::invalidate(backend, &target, &policy).await;
327        if result.is_err() {
328            disable(&self.flags);
329        }
330        result
331    }
332
333    fn policy(&self) -> Policy {
334        Policy {
335            default_ttl: self.default_ttl,
336            max_body: self.max_body,
337            bypass: Arc::clone(&self.bypass),
338            keys: Arc::clone(&self.keys),
339            local: Arc::clone(&self.local),
340            proxies: self.proxies.clone(),
341            flags: Arc::clone(&self.flags),
342            max_key: self.max_key,
343            max_headers: self.max_headers,
344            max_encoded: self.max_encoded,
345            generation_ttl: self.generation_ttl,
346            max_new_targets: self.max_new_targets,
347        }
348    }
349
350    /// Also store responses without `Cache-Control: public`, for `ttl` when
351    /// they carry no `max-age`. Meant for a layer on a single route (see the
352    /// [type docs](RouteCache)). A `ttl` of zero stores nothing.
353    #[must_use]
354    pub fn default_ttl(mut self, ttl: Duration) -> Self {
355        self.default_ttl = Some(ttl);
356        self
357    }
358
359    /// Bypass the cache for requests carrying `name` (a credential header).
360    #[must_use]
361    pub fn bypass_header(mut self, name: HeaderName) -> Self {
362        Arc::make_mut(&mut self.bypass).push(name);
363        self
364    }
365
366    /// Store only bodies of at most `bytes` (default
367    /// [`DEFAULT_MAX_BODY_BYTES`]).
368    #[must_use]
369    pub fn max_body_bytes(mut self, bytes: u64) -> Self {
370        self.max_body = bytes;
371        self
372    }
373}
374
375/// Per-request view of the layer configuration.
376struct Policy {
377    default_ttl: Option<Duration>,
378    max_body: u64,
379    bypass: Arc<Vec<HeaderName>>,
380    keys: Arc<generation::Keys>,
381    local: Arc<generation::Local>,
382    proxies: TrustedProxies,
383    flags: Arc<AtomicU8>,
384    max_key: usize,
385    max_headers: usize,
386    max_encoded: usize,
387    generation_ttl: Duration,
388    max_new_targets: u64,
389}
390
391impl<C: Cache> Layer<BoxService> for RouteCache<C> {
392    type Service = BoxService;
393
394    fn layer(&self, inner: BoxService) -> Self::Service {
395        let cache = Arc::clone(&self.cache);
396        let policy = Arc::new(self.policy());
397        from_fn(move |req: Request, next: Next| {
398            let cache = Arc::clone(&cache);
399            let policy = Arc::clone(&policy);
400            async move { Dispatch { cache, policy }.run(req, next).await }
401        })
402        .layer(inner)
403    }
404}
405
406fn is_cacheable_method(method: &Method) -> bool {
407    method == Method::GET || method == Method::HEAD
408}
409
410fn request_bypasses_cache(headers: &HeaderMap, bypass: &[HeaderName]) -> bool {
411    bypass.iter().any(|name| headers.contains_key(name))
412        || headers.keys().any(looks_like_credential)
413}
414
415/// Header names that commonly carry credentials (`X-Auth-Token`,
416/// `X-Session-Id`, `X-Jwt`, `X-Access-Key`, ...). Errs toward bypassing: a
417/// false positive only skips the cache, a false negative serves a private
418/// response to anyone.
419fn looks_like_credential(name: &HeaderName) -> bool {
420    const NEEDLES: [&str; 8] = [
421        "auth",
422        "token",
423        "session",
424        "jwt",
425        "secret",
426        "api-key",
427        "apikey",
428        "access-key",
429    ];
430    let name = name.as_str();
431    NEEDLES.iter().any(|needle| name.contains(needle))
432}
433
434/// True when `Vary` names `*` or a header that is not part of the key.
435fn varies_outside_key(headers: &HeaderMap) -> bool {
436    headers
437        .get_all(VARY)
438        .iter()
439        .flat_map(|value| value.to_str().unwrap_or("*").split(','))
440        .map(str::trim)
441        .filter(|name| !name.is_empty())
442        .any(|name| {
443            !KEYED_HEADERS
444                .iter()
445                .any(|keyed| keyed.as_str().eq_ignore_ascii_case(name))
446        })
447}
448
449/// `Cache-Control` directives as lowercase `(name, value)` pairs.
450fn cache_control(headers: &HeaderMap) -> Vec<(String, Option<String>)> {
451    headers
452        .get_all(CACHE_CONTROL)
453        .iter()
454        .filter_map(|value| value.to_str().ok())
455        .flat_map(|text| text.split(','))
456        .filter_map(|directive| {
457            let (name, value) = match directive.split_once('=') {
458                Some((name, value)) => {
459                    let value = value.trim().trim_matches('"').to_owned();
460                    (name, Some(value))
461                }
462                None => (directive, None),
463            };
464            let name = name.trim().trim_matches('"').to_ascii_lowercase();
465            (!name.is_empty()).then_some((name, value))
466        })
467        .collect()
468}
469
470/// True when any `Cache-Control` directive is `no-store`, `no-cache` or
471/// `private`. This layer never revalidates, so `no-cache` means "do not
472/// store" here.
473pub(crate) fn cache_control_forbids_store(headers: &HeaderMap) -> bool {
474    cache_control(headers)
475        .iter()
476        .any(|(name, _)| forbids_store(name))
477}
478
479fn forbids_store(name: &str) -> bool {
480    matches!(name, "no-store" | "no-cache" | "private")
481}
482
483/// How long to store `response`, or `None` when it must not be stored.
484fn storable_ttl(response: &Response, policy: &Policy) -> Option<Duration> {
485    let headers = response.headers();
486    let eligible = response.status() == StatusCode::OK
487        && !headers.contains_key(SET_COOKIE)
488        && !cache_control_forbids_store(headers)
489        && !varies_outside_key(headers)
490        && !headers.contains_key(header::TRAILER)
491        && response
492            .body()
493            .exact_len()
494            .is_some_and(|len| len <= policy.max_body);
495    if !eligible {
496        return None;
497    }
498    let directives = cache_control(headers);
499    let public = directives.iter().any(|(name, _)| name == "public");
500    if !public && policy.default_ttl.is_none() {
501        return None;
502    }
503    let seconds = |wanted: &str| {
504        directives
505            .iter()
506            .find(|(name, _)| name == wanted)
507            .and_then(|(_, value)| value.as_deref()?.parse::<u64>().ok())
508    };
509    let ttl = seconds("s-maxage")
510        .or_else(|| seconds("max-age"))
511        .map(Duration::from_secs)
512        .or(policy.default_ttl)?;
513    let remaining = ttl.checked_sub(codec::initial_age(headers)?)?;
514    let remaining = remaining.min(policy.generation_ttl);
515    (remaining > Duration::ZERO).then_some(remaining)
516}
517
518/// Build the cache key from method, scheme, host, path and query, and the
519/// keyed headers. Every part is length-prefixed so different requests can
520/// never produce the same key.
521fn cache_key(req: &Request, scheme: &str) -> String {
522    let mut key = String::new();
523    push_part(&mut key, req.method().as_str().as_bytes());
524    push_part(&mut key, scheme.as_bytes());
525    let host = req
526        .headers()
527        .get(HOST)
528        .map(HeaderValue::as_bytes)
529        .or_else(|| req.uri().authority().map(|a| a.as_str().as_bytes()))
530        .unwrap_or_default();
531    push_part(&mut key, &host.to_ascii_lowercase());
532    let target = req
533        .uri()
534        .path_and_query()
535        .map_or("/", http::uri::PathAndQuery::as_str);
536    push_part(&mut key, target.as_bytes());
537    for name in &KEYED_HEADERS {
538        let values = req.headers().get_all(name);
539        key.push_str(&format!("#{}", values.iter().count()));
540        for value in values {
541            push_part(&mut key, value.as_bytes());
542        }
543    }
544    key
545}
546
547/// Append length-prefixed UTF-8 text or hex-encoded non-text bytes.
548fn push_part(key: &mut String, bytes: &[u8]) {
549    use std::fmt::Write as _;
550    match std::str::from_utf8(bytes) {
551        Ok(text) => {
552            let _ = write!(key, "s{}:{text}", text.len());
553        }
554        Err(_) => {
555            const HEX: &[u8; 16] = b"0123456789abcdef";
556            let _ = write!(key, "x{}:", bytes.len());
557            for byte in bytes {
558                key.push(char::from(HEX[usize::from(byte >> 4)]));
559                key.push(char::from(HEX[usize::from(byte & 15)]));
560            }
561        }
562    }
563}
564
565fn with_x_cache(mut response: Response, value: HeaderValue) -> Response {
566    response.headers_mut().insert(X_CACHE, value);
567    response
568}
569
570/// Rebuild a cached response. A `HEAD` keeps its stored `Content-Length`
571/// (the size of the `GET` representation); a `GET` gets its body length.
572fn restore(method: &Method, headers: HeaderMap, body: Vec<u8>) -> Response {
573    let len = HeaderValue::from(body.len());
574    let mut response = Response::new(Body::from(body));
575    *response.status_mut() = StatusCode::OK;
576    *response.headers_mut() = headers;
577    if method != Method::HEAD {
578        response.headers_mut().insert(header::CONTENT_LENGTH, len);
579    }
580    with_x_cache(response, HIT)
581}
582
583fn disable(flags: &AtomicU8) {
584    flags.fetch_or(DISABLED, Ordering::Release);
585    tracing::warn!("cache invalidation failed; cache layer disabled");
586}
587
588fn header_bytes(headers: &HeaderMap) -> usize {
589    headers.iter().fold(0usize, |size, (name, value)| {
590        size.saturating_add(name.as_str().len())
591            .saturating_add(value.as_bytes().len())
592    })
593}
594
595fn unsupported_request(headers: &HeaderMap) -> bool {
596    [
597        header::RANGE,
598        header::IF_RANGE,
599        header::IF_MATCH,
600        header::IF_NONE_MATCH,
601        header::IF_MODIFIED_SINCE,
602        header::IF_UNMODIFIED_SINCE,
603    ]
604    .iter()
605    .any(|header| headers.contains_key(header))
606}
607
608fn requests_refresh(items: &[Directive], h: &HeaderMap) -> bool {
609    let forced = items.iter().any(|(name, _)| needs_refresh(name));
610    let pragma = h.get(header::PRAGMA).is_some_and(pragma_no_cache);
611    forced || pragma
612}
613
614fn needs_refresh(name: &str) -> bool {
615    matches!(name, "no-cache" | "max-age" | "min-fresh")
616}
617
618fn pragma_no_cache(value: &HeaderValue) -> bool {
619    value.as_bytes().eq_ignore_ascii_case(b"no-cache")
620}
621
622async fn lookup<C: Cache>(cache: &C, key: &str, policy: &Policy) -> Hit {
623    let bytes = cache.get(key).await.ok()??;
624    if bytes.len() > policy.max_encoded {
625        return None;
626    }
627    decode(&bytes, policy.max_headers, policy.max_body)
628}
629
630/// Where a fill stores: under a known marker, or after admission.
631enum Slot {
632    Known(String),
633    Absent(generation::Started, String),
634}
635
636struct Dispatch<C> {
637    cache: Arc<C>,
638    policy: Arc<Policy>,
639}
640
641impl<C: Cache> Dispatch<C> {
642    async fn run(self, req: Request, next: Next) -> Response {
643        let Self { cache, policy } = self;
644        let flags = policy.flags.load(Ordering::Acquire);
645        if flags & DISABLED != 0 {
646            return with_x_cache(next.run(req).await, MISS);
647        }
648        let context = match policy.proxies.resolve(&req) {
649            Ok(context) => context,
650            Err(_) => {
651                let error = ApiError::bad_request("Invalid proxy context.");
652                return error.into_response();
653            }
654        };
655        let target = generation::target(&req, &policy, context.scheme);
656        if target.len() > policy.max_key {
657            return with_x_cache(next.run(req).await, MISS);
658        }
659        if !is_cacheable_method(req.method()) {
660            let write = generation::unsafe_method(req.method());
661            let invalidate = write && flags & NO_MARKERS == 0;
662            let response = next.run(req).await;
663            let status = response.status();
664            let succeeded = status.is_success() || status.is_redirection();
665            if invalidate
666                && succeeded
667                && generation::invalidate(cache.as_ref(), &target, &policy)
668                    .await
669                    .is_err()
670            {
671                // A completed write cannot be undone by a cache failure.
672                // Disable this layer rather than reuse stale local data.
673                disable(&policy.flags);
674            }
675            return response;
676        }
677        let directives = cache_control(req.headers());
678        let no_store = directives.iter().any(|(name, _)| name == "no-store");
679        let refresh = requests_refresh(&directives, req.headers());
680        if no_store
681            || unsupported_request(req.headers())
682            || request_bypasses_cache(req.headers(), &policy.bypass)
683        {
684            return with_x_cache(next.run(req).await, MISS);
685        }
686        if flags & NO_MARKERS != 0 || policy.max_new_targets == 0 {
687            // No expiring markers: unsupported backend, zero lifetime or
688            // a zero admission budget.
689            return with_x_cache(next.run(req).await, MISS);
690        }
691        let backend = cache.as_ref();
692        let known = match generation::peek(backend, &target).await {
693            Ok(known) => known,
694            Err(_) => return with_x_cache(next.run(req).await, MISS),
695        };
696        let scheme = context.scheme;
697        let (slot, hit) = match known {
698            Some(generation) => {
699                let key = make_key(&req, &policy, scheme, generation);
700                if key.len() > policy.max_key {
701                    return with_x_cache(next.run(req).await, MISS);
702                }
703                let hit = if refresh {
704                    None
705                } else {
706                    lookup(backend, &key, &policy).await
707                };
708                (Slot::Known(key), hit)
709            }
710            // No marker: nothing is reachable, and nothing is created
711            // until a response is actually stored.
712            None if policy.local.spent() => {
713                // This window's budget is known spent: skip the epoch and
714                // admission calls, the request would only be refused.
715                return with_x_cache(next.run(req).await, MISS);
716            }
717            None => match generation::begin(backend, &policy, &target).await {
718                Ok(started) => {
719                    let suffix = cache_key(&req, scheme);
720                    (Slot::Absent(started, suffix), None)
721                }
722                Err(_) => return with_x_cache(next.run(req).await, MISS),
723            },
724        };
725        let method = req.method().clone();
726        if let Some((headers, body)) = hit {
727            return restore(&method, headers, body);
728        }
729        let response = next.run(req).await;
730        let Some(ttl) = storable_ttl(&response, &policy) else {
731            return with_x_cache(response, MISS);
732        };
733        if header_bytes(response.headers()) > policy.max_headers {
734            return with_x_cache(response, MISS);
735        }
736        let key = match slot {
737            Slot::Known(key) => Some(key),
738            Slot::Absent(started, suffix) => {
739                let admitted = generation::admit(backend, &target, &policy, &started);
740                match admitted.await {
741                    Ok(Some(generation)) => {
742                        let key = generation::key_for(&policy, generation, &suffix);
743                        (key.len() <= policy.max_key).then_some(key)
744                    }
745                    _ => None,
746                }
747            }
748        };
749        let Some(key) = key else {
750            return with_x_cache(response, MISS);
751        };
752        let (mut parts, body) = response.into_parts();
753        let limit = usize::try_from(policy.max_body).unwrap_or(usize::MAX);
754        let bytes = match body.into_bytes_limited(limit).await {
755            Ok(bytes) => bytes,
756            Err(_) => {
757                let error = ApiError::internal("Cache body read failed.");
758                return error.into_response();
759            }
760        };
761        if let Ok(payload) = encode(&parts.headers, &bytes)
762            && payload.len() <= policy.max_encoded
763        {
764            let _ = cache.set(&key, payload, Some(ttl)).await;
765        }
766        parts.headers.insert(X_CACHE, MISS);
767        Response::from_parts(parts, Body::from(bytes))
768    }
769}
770
771#[cfg(test)]
772mod tests {
773    use super::*;
774    use http::HeaderValue;
775
776    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
777        let mut map = HeaderMap::new();
778        for (name, value) in pairs {
779            map.append(
780                HeaderName::try_from(*name).unwrap(),
781                HeaderValue::from_str(value).unwrap(),
782            );
783        }
784        map
785    }
786
787    #[test]
788    fn cache_control_detects_no_store_no_cache_and_private() {
789        assert!(cache_control_forbids_store(&headers(&[(
790            "cache-control",
791            "no-store"
792        )])));
793        assert!(cache_control_forbids_store(&headers(&[(
794            "cache-control",
795            "max-age=0, Private"
796        )])));
797        assert!(cache_control_forbids_store(&headers(&[(
798            "cache-control",
799            "public, no-store, max-age=60"
800        )])));
801        assert!(cache_control_forbids_store(&headers(&[
802            ("cache-control", "max-age=60"),
803            ("cache-control", "private=\"Set-Cookie\""),
804        ])));
805        assert!(!cache_control_forbids_store(&headers(&[(
806            "cache-control",
807            "public, max-age=60"
808        )])));
809        assert!(cache_control_forbids_store(&headers(&[(
810            "cache-control",
811            "No-Cache"
812        )])));
813        assert!(!cache_control_forbids_store(&HeaderMap::new()));
814    }
815
816    #[test]
817    fn encode_decode_roundtrip_strips_hop_by_hop() {
818        let mut map = headers(&[
819            ("content-type", "text/plain"),
820            ("x-foo", "bar"),
821            ("transfer-encoding", "chunked"),
822            ("x-cache", "miss"),
823        ]);
824        map.append("x-foo", HeaderValue::from_static("baz"));
825        let encoded = encode(&map, b"hello").unwrap();
826        let (decoded, body) = decode(&encoded, 8192, 1024).unwrap();
827        assert_eq!(body, b"hello");
828        assert_eq!(
829            decoded
830                .get_all("x-foo")
831                .iter()
832                .map(|v| v.as_bytes())
833                .collect::<Vec<_>>(),
834            [b"bar".as_slice(), b"baz".as_slice()]
835        );
836        assert!(decoded.get("transfer-encoding").is_none());
837        assert!(decoded.get("x-cache").is_none());
838        assert_eq!(
839            decoded.get("content-type").unwrap().as_bytes(),
840            b"text/plain"
841        );
842    }
843
844    #[test]
845    fn decode_rejects_garbage() {
846        assert!(decode(b"not-json", 8192, 1024).is_none());
847        assert!(decode(b"{}", 8192, 1024).is_none());
848    }
849}