Skip to main content

featherbit/plugins/native/
proxy_cache.rs

1//! Response caching (`proxy-cache`), ported from APISIX's
2//! `apisix/plugins/proxy-cache`.
3//!
4//! Serving a cached response means *looking up* the cache before the upstream
5//! call and *storing* the fresh response after it — two moments a single
6//! featherbit node cannot span. The behavior is expressed as a **pair of
7//! nodes** sharing one cache, linked by a required `id`:
8//!
9//! - a **lookup** node placed *before* `upstream`, which serves a cache hit
10//!   straight to the client (short-circuiting the upstream call), and
11//! - a **store** node placed *after* `upstream`, which caches a fresh response
12//!   for later hits.
13//!
14//! Both nodes derive the cache key identically from the same `cache_key`
15//! template and the request, and share one namespace via `id`, so they always
16//! agree. State lives behind a [`crate::traffic::ResponseCache`].
17//!
18//! # Wiring
19//!
20//! ```text
21//!            ┌──────────────────┐        ┌──────────┐        ┌──────────────────┐
22//!  listener →│ proxy-cache      │success →│ upstream │success →│ proxy-cache      │→ client
23//!            │  (phase=lookup)  │        │          │        │  (phase=store)   │
24//!            └──────────────────┘        └──────────┘        └──────────────────┘
25//!                    │ hit                                      (caches responses
26//!                    ▼                                        whose status is cacheable)
27//!               client.in
28//!         (cached response, HIT)
29//! ```
30//!
31//! On a hit, the lookup node writes the cached response onto the context, adds
32//! `featherbit-cache-status: HIT`, and exits through the dedicated `hit`
33//! port — wired to `client.in`, delivering the cached response without
34//! touching the upstream. On a miss it passes through `success`; the store
35//! node then caches the upstream response and marks it
36//! `featherbit-cache-status: MISS`.
37
38use async_trait::async_trait;
39use std::collections::HashMap;
40use std::sync::Arc;
41use std::time::Duration;
42
43use crate::context::Context;
44use crate::plugins::resources::PluginResources;
45use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
46use crate::traffic::{CachedResponse, ResponseCache};
47use crate::vars::template::Template;
48
49/// Header written by both nodes to report the cache outcome.
50const CACHE_STATUS_HEADER: &str = "featherbit-cache-status";
51/// Response headers hidden from clients when `hide_cache_headers` is set.
52const HIDDEN_HEADERS: &[&str] = &["cache-control", "expires"];
53
54/// The `policy` values this build actually supports — naming `redis` on a
55/// headless build would describe an option that cannot work.
56#[cfg(feature = "redis-store")]
57const SUPPORTED_POLICIES: &str = "local, redis";
58#[cfg(not(feature = "redis-store"))]
59const SUPPORTED_POLICIES: &str = "local";
60
61/// Which half of the pair this node is.
62#[derive(Debug, Clone, Copy, PartialEq)]
63enum Role {
64    /// Runs before `upstream`: serves a cache hit.
65    Lookup,
66    /// Runs after `upstream`: stores a fresh response.
67    Store,
68    /// Runs on a write path: invalidates everything its pair cached.
69    Purge,
70}
71
72/// One node of a `proxy-cache` lookup/store pair.
73///
74/// Holds a handle to the configured [`ResponseCache`] backend; the key is
75/// derived per request from `cache_key`, namespaced by `id`.
76pub struct ProxyCachePlugin {
77    role: Role,
78    /// Shared cache namespace — links the lookup and store nodes.
79    id: String,
80    /// Cache-key components, each rendered (supports `{{namespace.path}}`
81    /// references and legacy `$var` interpolation — see
82    /// [`Template::render_with_legacy`]) and joined per request.
83    cache_key: Vec<Template>,
84    /// Freshness lifetime for stored entries.
85    cache_ttl: Duration,
86    /// Response statuses eligible for caching.
87    cache_statuses: Vec<u16>,
88    /// HTTP methods eligible for caching (uppercase).
89    cache_methods: Vec<String>,
90    /// When set, hides upstream cache headers from served cache hits.
91    hide_cache_headers: bool,
92    /// The backend this node pair shares, chosen by `policy`: the process-local
93    /// cache for `local`, or a shared `RedisResponseCache` over a declared
94    /// `stores:` entry for `redis`.
95    cache: Arc<dyn ResponseCache>,
96    /// Label for the `backend` dimension of `cache_events`.
97    backend_label: &'static str,
98    /// Label for the `store` dimension of `cache_events`: the declared
99    /// store's name for `policy: redis`, empty for `policy: local` (which has
100    /// no store to name).
101    store_label: String,
102    /// A response body larger than this is served but never cached — one
103    /// large response must not be able to fill a store that sessions,
104    /// counters and ACME also live in.
105    max_object_bytes: usize,
106    /// Process-wide services, held for the metrics registry.
107    resources: Arc<PluginResources>,
108}
109
110impl ProxyCachePlugin {
111    /// Builds one node of the pair from node config.
112    ///
113    /// Accepted keys:
114    /// - `phase` / `role` (string, **required**): `lookup` (before upstream) or
115    ///   `store` (after upstream).
116    /// - `id` (string, **required**): shared cache namespace; the lookup and
117    ///   store nodes of one pair must use the same `id`.
118    /// - `cache_key` (array of string templates **or** a single string,
119    ///   default `["$request_method", "$host", "$uri"]`): components rendered
120    ///   (supports `{{namespace.path}}` references plus legacy `$var`
121    ///   interpolation — see
122    ///   [`crate::vars::template::Template::render_with_legacy`]) and joined
123    ///   to form the key. Both nodes must configure it identically.
124    /// - `cache_ttl` (integer seconds, default `300`): freshness lifetime.
125    /// - `cache_http_statuses` (array, default `[200, 301, 404]`): statuses
126    ///   eligible for caching. (`cache_http_status`, APISIX's singular spelling,
127    ///   is also accepted.)
128    /// - `cache_method` (array, default `["GET", "HEAD"]`): cacheable methods.
129    /// - `hide_cache_headers` (bool, default `false`): strip `cache-control` /
130    ///   `expires` from served cache hits.
131    /// - `max_object_bytes` (integer, default `1048576`): responses larger
132    ///   than this are served normally but never cached, in either backend.
133    ///
134    /// ```yaml
135    /// # before upstream
136    /// type: proxy-cache
137    /// config:
138    ///   phase: lookup
139    ///   id: catalog
140    ///   cache_key: ["$request_method", "$host", "$uri"]
141    ///   cache_ttl: 300
142    /// ---
143    /// # after upstream
144    /// type: proxy-cache
145    /// config:
146    ///   phase: store
147    ///   id: catalog
148    ///   cache_key: ["$request_method", "$host", "$uri"]
149    ///   cache_ttl: 300
150    ///   cache_http_statuses: [200, 301, 404]
151    /// ```
152    pub fn from_config(
153        config: &HashMap<String, serde_json::Value>,
154        resources: &Arc<PluginResources>,
155    ) -> Result<Self, String> {
156        let role =
157            match config
158                .get("phase")
159                .or_else(|| config.get("role"))
160                .and_then(|v| v.as_str())
161            {
162                Some("lookup") => Role::Lookup,
163                Some("store") => Role::Store,
164                Some("purge") => Role::Purge,
165                Some(other) => {
166                    return Err(format!(
167                    "proxy-cache: unknown phase/role '{}' (expected 'lookup', 'store' or 'purge')",
168                    other
169                ))
170                }
171                None => return Err(
172                    "proxy-cache: 'phase' (or 'role') is required: 'lookup', 'store' or 'purge'"
173                        .to_string(),
174                ),
175            };
176
177        let id = config
178            .get("id")
179            .and_then(|v| v.as_str())
180            .filter(|s| !s.trim().is_empty())
181            .ok_or("proxy-cache: 'id' is required (links the lookup/store pair)")?
182            .to_string();
183
184        // The `\u{1}` separator is the prefix boundary that purging a pair
185        // relies on: an `id` containing it would produce keys another pair's
186        // prefix also matches. Refuse it -- and every other control character,
187        // which have no business in a cache namespace -- at compile time
188        // rather than trust config never to contain one.
189        if id.chars().any(char::is_control) {
190            return Err(format!(
191                "proxy-cache: 'id' must not contain control characters (got {:?})",
192                id
193            ));
194        }
195
196        let cache_key: Vec<String> = match config.get("cache_key") {
197            None => vec![
198                "$request_method".to_string(),
199                "$host".to_string(),
200                "$uri".to_string(),
201            ],
202            Some(serde_json::Value::String(s)) => vec![s.clone()],
203            Some(serde_json::Value::Array(items)) => {
204                let mut out = Vec::with_capacity(items.len());
205                for item in items {
206                    let s = item
207                        .as_str()
208                        .ok_or("proxy-cache: cache_key entries must be strings")?;
209                    out.push(s.to_string());
210                }
211                if out.is_empty() {
212                    return Err("proxy-cache: cache_key must not be empty".to_string());
213                }
214                out
215            }
216            Some(_) => {
217                return Err(
218                    "proxy-cache: cache_key must be a string or an array of strings".to_string(),
219                )
220            }
221        };
222        // Discard warnings here — the compile-time walk (a later task)
223        // reports well-formed-but-unknown references; execution must not.
224        let cache_key: Vec<Template> = cache_key.iter().map(|s| Template::parse(s).0).collect();
225
226        let ttl_secs = config
227            .get("cache_ttl")
228            .and_then(|v| v.as_u64())
229            .unwrap_or(300);
230        if ttl_secs == 0 {
231            return Err("proxy-cache: cache_ttl must be >= 1 second".to_string());
232        }
233
234        let cache_statuses = parse_statuses(
235            config
236                .get("cache_http_statuses")
237                .or_else(|| config.get("cache_http_status")),
238        )?
239        .unwrap_or_else(|| vec![200, 301, 404]);
240
241        let cache_methods = match config.get("cache_method") {
242            None => vec!["GET".to_string(), "HEAD".to_string()],
243            Some(v) => {
244                let arr = v
245                    .as_array()
246                    .ok_or("proxy-cache: cache_method must be an array of strings")?;
247                let mut out = Vec::with_capacity(arr.len());
248                for item in arr {
249                    let m = item
250                        .as_str()
251                        .ok_or("proxy-cache: cache_method entries must be strings")?;
252                    out.push(m.to_uppercase());
253                }
254                if out.is_empty() {
255                    return Err("proxy-cache: cache_method must not be empty".to_string());
256                }
257                out
258            }
259        };
260
261        let hide_cache_headers = config
262            .get("hide_cache_headers")
263            .and_then(|v| v.as_bool())
264            .unwrap_or(false);
265
266        let max_object_bytes = config
267            .get("max_object_bytes")
268            .and_then(|v| v.as_u64())
269            .unwrap_or(1_048_576) as usize;
270
271        let policy = config
272            .get("policy")
273            .and_then(|v| v.as_str())
274            .unwrap_or("local");
275        let (cache, backend_label, store_label): (Arc<dyn ResponseCache>, &'static str, String) =
276            match policy {
277                "local" => (resources.traffic.cache.clone(), "local", String::new()),
278                #[cfg(feature = "redis-store")]
279                "redis" => {
280                    let name = config
281                        .get("store")
282                        .and_then(|v| v.as_str())
283                        .filter(|s| !s.is_empty())
284                        .ok_or_else(|| {
285                            "proxy-cache: policy 'redis' requires 'store' naming a declared stores: entry"
286                                .to_string()
287                        })?;
288                    let client = resources.stores.load().client(name)?;
289                    (
290                        Arc::new(crate::stores::redis_cache::RedisResponseCache::new(client)),
291                        "redis",
292                        name.to_string(),
293                    )
294                }
295                other => {
296                    return Err(format!(
297                        "proxy-cache: unknown policy '{other}' — supported: {}",
298                        SUPPORTED_POLICIES
299                    ))
300                }
301            };
302
303        Ok(Self {
304            role,
305            id,
306            cache_key,
307            cache_ttl: Duration::from_secs(ttl_secs),
308            cache_statuses,
309            cache_methods,
310            hide_cache_headers,
311            cache,
312            backend_label,
313            store_label,
314            max_object_bytes,
315            resources: resources.clone(),
316        })
317    }
318
319    /// Whether this request's method is cacheable.
320    fn method_cacheable(&self, ctx: &Context) -> bool {
321        let method = ctx.request.method.to_uppercase();
322        self.cache_methods.contains(&method)
323    }
324
325    /// Derives the cache key: `id` namespace + `cache_key` components
326    /// (each rendered via `{{namespace.path}}` references plus legacy `$var`
327    /// interpolation) joined by a control-char separator (outside the
328    /// character set of any header/method/path, so components can't
329    /// collide).
330    fn derive_key(&self, ctx: &Context) -> String {
331        let mut key = String::with_capacity(64);
332        key.push_str(&self.id);
333        for component in &self.cache_key {
334            key.push('\u{1}');
335            key.push_str(&component.render_with_legacy(ctx));
336        }
337        key
338    }
339
340    /// Counts one cache outcome. A no-op when metrics are disabled (unit tests).
341    fn record(&self, event: &str) {
342        if let Some(metrics) = &self.resources.metrics {
343            metrics
344                .cache_events
345                .with_label_values(&[self.backend_label, &self.store_label, event])
346                .inc();
347        }
348    }
349}
350
351/// Reads a `Vec<u16>` of HTTP statuses from a JSON array, if present and valid.
352fn parse_statuses(v: Option<&serde_json::Value>) -> Result<Option<Vec<u16>>, String> {
353    let Some(v) = v else { return Ok(None) };
354    let arr = v
355        .as_array()
356        .ok_or("proxy-cache: cache_http_statuses must be an array of integers")?;
357    let mut out = Vec::with_capacity(arr.len());
358    for item in arr {
359        let n = item
360            .as_u64()
361            .ok_or("proxy-cache: cache_http_statuses entries must be integers")?;
362        if !(200..=599).contains(&n) {
363            return Err(format!(
364                "proxy-cache: cache status {} is out of range (200-599)",
365                n
366            ));
367        }
368        out.push(n as u16);
369    }
370    if out.is_empty() {
371        return Err("proxy-cache: cache_http_statuses must not be empty".to_string());
372    }
373    Ok(Some(out))
374}
375
376#[async_trait]
377impl Plugin for ProxyCachePlugin {
378    fn plugin_type(&self) -> &str {
379        "proxy-cache"
380    }
381
382    /// A purge-only policy still names a real backend for its `id`: dedup in
383    /// `collect_targets` makes a duplicate target (from a paired lookup/store
384    /// half also naming it) harmless, and the alternative -- `None` here --
385    /// would make `DELETE /api/cache/{id}` 404 for an id only a purge half
386    /// names, which is worse than the actual consequence: it 200s with
387    /// `removed: 0` when nothing else has ever cached under that id.
388    fn cache_target(&self) -> Option<crate::traffic::CacheTarget> {
389        Some(crate::traffic::CacheTarget {
390            id: self.id.clone(),
391            backend: self.cache.clone(),
392            backend_label: self.backend_label,
393            store: self.store_label.clone(),
394        })
395    }
396
397    /// Only `Lookup` opts out of buffering. It never reads the existing
398    /// response body -- on a hit it replaces `ctx.response` outright with the
399    /// cached entry, on a miss it passes the context through untouched -- and
400    /// it never returns `Err` from `execute`: its only fallible call
401    /// (`self.cache.get`) is matched and degraded to a miss, not propagated.
402    ///
403    /// `Store` reads `ctx.response.body` to cache it, so it must buffer.
404    ///
405    /// `Purge` reads nothing from the response either, but is deliberately
406    /// kept buffering anyway: it *can* return `Err` from `execute`
407    /// (`run_purge`, on a failed backend), and the engine's forward
408    /// streaming walk (`infer_stream_capability` in `src/graph/engine.rs`)
409    /// is only sound for opt-out nodes that never do that -- see the safety
410    /// argument in its doc comment. Opting `Purge` out too would let a
411    /// stream-capable upstream's response stay live past a purge node that
412    /// then routes to `error`, exactly the stale-`response.stream`-beside-a-
413    /// generated-body case that invariant exists to rule out.
414    fn reads_response_body(&self) -> bool {
415        !matches!(self.role, Role::Lookup)
416    }
417
418    async fn execute(&self, mut ctx: Context) -> PluginResult {
419        // A purge neither produces nor consumes a cached representation: it
420        // doesn't read or write an entry keyed to this request, so the
421        // method gate and key derivation below -- both about matching one
422        // request to one cached entry -- do not apply to it. Handle it
423        // before either runs, independent of the lookup/store gate.
424        if self.role == Role::Purge {
425            return self.run_purge(ctx).await;
426        }
427
428        // Non-cacheable methods bypass the cache entirely in the lookup and
429        // store phases.
430        if !self.method_cacheable(&ctx) {
431            return Ok(PluginOutput::success(ctx));
432        }
433
434        let key = self.derive_key(&ctx);
435
436        match self.role {
437            Role::Lookup => {
438                // A backend that cannot answer is treated as a miss: this
439                // cache exists to save a trip upstream, not to decide
440                // whether the request is allowed. Counted either way, so a
441                // fully-degraded cache stays visible.
442                let found = match self.cache.get(&key).await {
443                    Ok(found) => found,
444                    Err(e) => {
445                        tracing::warn!(key = %key, "proxy-cache lookup failed: {e}");
446                        self.record("error");
447                        None
448                    }
449                };
450                self.record(if found.is_some() { "hit" } else { "miss" });
451                if let Some(entry) = found {
452                    // Hit: serve the cached response and short-circuit to the
453                    // client via the `hit` port (→ client.in).
454                    ctx.response.status_code = entry.status;
455                    ctx.response.headers = entry.headers;
456                    ctx.response.body = entry.body;
457                    if self.hide_cache_headers {
458                        for h in HIDDEN_HEADERS {
459                            ctx.response.headers.remove(*h);
460                        }
461                    }
462                    ctx.response
463                        .headers
464                        .insert(CACHE_STATUS_HEADER.to_string(), vec!["HIT".to_string()]);
465
466                    return Ok(PluginOutput::on_port(ctx, "hit"));
467                }
468                // Miss: continue to the upstream.
469                Ok(PluginOutput::success(ctx))
470            }
471            Role::Store => {
472                let status = ctx.response.status_code;
473                // The size check only applies to a response that would
474                // otherwise have been cached — a non-cacheable status was
475                // never going to be stored regardless of its size, so it
476                // must not inflate a counter whose whole purpose is showing
477                // what the size limit excluded.
478                if self.cache_statuses.contains(&status) {
479                    if ctx.response.body.len() > self.max_object_bytes {
480                        // Metered so a route that mysteriously never caches is
481                        // explicable rather than mysterious.
482                        self.record("too_large");
483                    } else {
484                        let entry = CachedResponse {
485                            status,
486                            headers: ctx.response.headers.clone(),
487                            body: ctx.response.body.clone(),
488                        };
489                        if let Err(e) = self.cache.put(&key, &entry, self.cache_ttl).await {
490                            // Metered as well as logged: a response is served
491                            // correctly whether or not it was cached, so a write
492                            // that has stopped working leaves no other trace.
493                            tracing::warn!(key = %key, "proxy-cache store failed: {e}");
494                            self.record("error");
495                        }
496                    }
497                }
498                // This response came from the upstream, not the cache.
499                ctx.response
500                    .headers
501                    .insert(CACHE_STATUS_HEADER.to_string(), vec!["MISS".to_string()]);
502                Ok(PluginOutput::success(ctx))
503            }
504            // Handled unconditionally at the top of `execute`, before the
505            // method gate and key derivation that only apply to lookup/store.
506            Role::Purge => unreachable!("Role::Purge returns early in execute"),
507        }
508    }
509}
510
511impl ProxyCachePlugin {
512    /// Invalidates this node's pair, independent of request method or cache
513    /// key -- a purge acts on the shared `id` namespace, not on one derived
514    /// key, so neither concept applies here.
515    async fn run_purge(&self, ctx: Context) -> PluginResult {
516        // Reads degrade, purges report. A lookup that cannot reach its
517        // backend becomes a miss, because a cache exists to save latency.
518        // A purge is different in kind: the caller asked for state to
519        // change, and silently continuing would leave the cache stale
520        // in exactly the situation invalidation exists to fix.
521        match self.cache.purge(&self.id).await {
522            Ok(removed) => {
523                tracing::info!(id = %self.id, removed, "proxy-cache purged pair");
524                self.record("purge");
525                Ok(PluginOutput::success(ctx))
526            }
527            Err(e) => {
528                tracing::warn!(id = %self.id, "proxy-cache purge failed: {e}");
529                self.record("error");
530                Err(PluginExecutionError {
531                    context: ctx,
532                    error: crate::context::GatewayError {
533                        node_id: String::new(),
534                        code: "CACHE_PURGE_FAILED".to_string(),
535                        message: format!("proxy-cache: purging pair '{}' failed: {e}", self.id),
536                        metadata: HashMap::new(),
537                    },
538                })
539            }
540        }
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
548    use bytes::Bytes;
549
550    fn ctx(method: &str) -> Context {
551        Context {
552            request: GatewayRequest {
553                method: method.to_string(),
554                path: "/products".to_string(),
555                host: "shop.example".to_string(),
556                scheme: "http".to_string(),
557                headers: HashMap::new(),
558                query_params: HashMap::new(),
559                body: Bytes::new(),
560                remote_addr: "10.0.0.1:5000".to_string(),
561                protocol: Protocol::Http1,
562            },
563            response: GatewayResponse {
564                status_code: 0,
565                headers: HashMap::new(),
566                body: Bytes::new(),
567                stream: None,
568            },
569            message: HashMap::new(),
570            errors: Vec::new(),
571        }
572    }
573
574    fn cfg(pairs: &[(&str, serde_json::Value)]) -> HashMap<String, serde_json::Value> {
575        pairs
576            .iter()
577            .map(|(k, v)| (k.to_string(), v.clone()))
578            .collect()
579    }
580
581    /// A neutral cacheable request; the outage tests don't need to vary it.
582    fn test_context() -> Context {
583        ctx("GET")
584    }
585
586    /// A fresh metrics registry, as `src/graph/engine.rs`'s tests build one.
587    fn test_metrics() -> Arc<crate::metrics::GatewayMetrics> {
588        Arc::new(crate::metrics::GatewayMetrics::new())
589    }
590
591    /// A lookup-phase plugin around a given cache backend, metrics disabled.
592    /// A write that cannot reach its backend must be counted too.
593    ///
594    /// The lookup side already meters its failures, but a store-side outage
595    /// left no trace at all: the response is served correctly either way, so
596    /// nothing downstream notices that the cache has stopped being written.
597    /// That is the same invisibility the lookup counter exists to remove.
598    #[tokio::test]
599    async fn test_a_failing_store_increments_the_error_counter() {
600        let metrics = test_metrics();
601        let plugin = store_plugin_with_cache_and_metrics(Arc::new(BrokenCache), metrics.clone());
602
603        let mut ctx = test_context();
604        ctx.response.status_code = 200;
605        plugin.execute(ctx).await.unwrap();
606
607        assert_eq!(
608            metrics
609                .cache_events
610                .with_label_values(&["local", "", "error"])
611                .get(),
612            1,
613            "a failed write must be visible in metrics, not only in the log"
614        );
615    }
616
617    fn lookup_plugin_with_cache(cache: Arc<dyn ResponseCache>) -> ProxyCachePlugin {
618        let mut plugin = lookup(&PluginResources::empty());
619        plugin.cache = cache;
620        plugin
621    }
622
623    /// A purge-phase plugin around a given cache backend.
624    fn purge_plugin_with_cache(cache: Arc<dyn ResponseCache>) -> ProxyCachePlugin {
625        let mut plugin = ProxyCachePlugin::from_config(
626            &cfg(&[
627                ("phase", serde_json::json!("purge")),
628                ("id", serde_json::json!("cat")),
629            ]),
630            &PluginResources::empty(),
631        )
632        .unwrap();
633        plugin.cache = cache;
634        plugin
635    }
636
637    /// A lookup-phase plugin around a given cache backend and metrics registry.
638    fn lookup_plugin_with_cache_and_metrics(
639        cache: Arc<dyn ResponseCache>,
640        metrics: Arc<crate::metrics::GatewayMetrics>,
641    ) -> ProxyCachePlugin {
642        let mut plugin = lookup(&PluginResources::new(Some(metrics)));
643        plugin.cache = cache;
644        plugin
645    }
646
647    /// A store-phase plugin around a given cache backend and metrics registry.
648    fn store_plugin_with_cache_and_metrics(
649        cache: Arc<dyn ResponseCache>,
650        metrics: Arc<crate::metrics::GatewayMetrics>,
651    ) -> ProxyCachePlugin {
652        let mut plugin = store(&PluginResources::new(Some(metrics)));
653        plugin.cache = cache;
654        plugin
655    }
656
657    /// A store-phase plugin around a given cache backend and `max_object_bytes`.
658    fn store_plugin_with_cache_and_limit(
659        cache: Arc<dyn ResponseCache>,
660        max_object_bytes: usize,
661    ) -> ProxyCachePlugin {
662        let mut plugin = store(&PluginResources::empty());
663        plugin.cache = cache;
664        plugin.max_object_bytes = max_object_bytes;
665        plugin
666    }
667
668    fn lookup(r: &Arc<PluginResources>) -> ProxyCachePlugin {
669        ProxyCachePlugin::from_config(
670            &cfg(&[
671                ("phase", serde_json::json!("lookup")),
672                ("id", serde_json::json!("cat")),
673            ]),
674            r,
675        )
676        .unwrap()
677    }
678
679    fn store(r: &Arc<PluginResources>) -> ProxyCachePlugin {
680        ProxyCachePlugin::from_config(
681            &cfg(&[
682                ("phase", serde_json::json!("store")),
683                ("id", serde_json::json!("cat")),
684            ]),
685            r,
686        )
687        .unwrap()
688    }
689
690    #[test]
691    fn test_missing_id_and_bad_role_fail() {
692        let r = PluginResources::empty();
693        assert!(
694            ProxyCachePlugin::from_config(&cfg(&[("phase", serde_json::json!("lookup"))]), &r)
695                .is_err()
696        );
697        assert!(ProxyCachePlugin::from_config(
698            &cfg(&[
699                ("phase", serde_json::json!("bogus")),
700                ("id", serde_json::json!("x"))
701            ]),
702            &r
703        )
704        .is_err());
705    }
706
707    /// The separator is the prefix boundary purge relies on. An `id` that
708    /// contains it would produce keys another pair's prefix also matches, so
709    /// it is refused at policy-compile time rather than trusted not to happen.
710    #[test]
711    fn test_an_id_containing_the_separator_is_rejected() {
712        let r = PluginResources::empty();
713        // `ProxyCachePlugin` holds an `Arc<dyn ResponseCache>`, so it has no
714        // `Debug` impl and can't go through `unwrap_err()`; match instead,
715        // as `test_unknown_policy_names_only_what_this_build_supports` does.
716        let err = match ProxyCachePlugin::from_config(
717            &cfg(&[
718                ("phase", serde_json::json!("lookup")),
719                ("id", serde_json::json!("products\u{1}x")),
720            ]),
721            &r,
722        ) {
723            Err(e) => e,
724            Ok(_) => panic!("an id containing the separator must fail from_config"),
725        };
726        assert!(err.contains("control character"), "{err}");
727    }
728
729    /// An unknown `policy` must name only the policies this build actually
730    /// supports — `redis` on a headless build describes an option that
731    /// cannot work.
732    #[test]
733    fn test_unknown_policy_names_only_what_this_build_supports() {
734        let r = PluginResources::empty();
735        let err = match ProxyCachePlugin::from_config(
736            &cfg(&[
737                ("phase", serde_json::json!("lookup")),
738                ("id", serde_json::json!("x")),
739                ("policy", serde_json::json!("bogus")),
740            ]),
741            &r,
742        ) {
743            Err(e) => e,
744            Ok(_) => panic!("an unknown policy must fail from_config"),
745        };
746        assert!(err.contains("local"), "{err}");
747        #[cfg(feature = "redis-store")]
748        assert!(err.contains("redis"), "{err}");
749        #[cfg(not(feature = "redis-store"))]
750        assert!(!err.contains("redis"), "{err}");
751    }
752
753    #[test]
754    fn test_key_derivation_is_deterministic_and_shared() {
755        let r = PluginResources::empty();
756        let l = lookup(&r);
757        let s = store(&r);
758        // Both nodes derive the same key from the same request + config.
759        assert_eq!(l.derive_key(&ctx("GET")), s.derive_key(&ctx("GET")));
760        // Method participates in the default key.
761        assert_ne!(l.derive_key(&ctx("GET")), l.derive_key(&ctx("HEAD")));
762    }
763
764    #[tokio::test]
765    async fn test_store_then_lookup_returns_hit() {
766        let r = PluginResources::empty();
767        let l = lookup(&r);
768        let s = store(&r);
769
770        // Cold lookup → miss (passes through).
771        let miss = l.execute(ctx("GET")).await.unwrap();
772        assert!(
773            miss.port.is_none(),
774            "cold lookup should miss and pass through"
775        );
776
777        // Upstream produced a 200 body → store caches it.
778        let mut resp = ctx("GET");
779        resp.response.status_code = 200;
780        resp.response.body = Bytes::from_static(b"cached-body");
781        let stored = s.execute(resp).await.unwrap();
782        assert_eq!(
783            stored.context.response.headers.get(CACHE_STATUS_HEADER),
784            Some(&vec!["MISS".to_string()])
785        );
786
787        // Warm lookup → hit, short-circuits with the cached body on the `hit` port.
788        let hit = l
789            .execute(ctx("GET"))
790            .await
791            .expect("warm lookup should hit and short-circuit");
792        assert_eq!(hit.port, Some("hit"));
793        assert_eq!(hit.context.response.status_code, 200);
794        assert_eq!(
795            hit.context.response.body,
796            Bytes::from_static(b"cached-body")
797        );
798        assert_eq!(
799            hit.context.response.headers.get(CACHE_STATUS_HEADER),
800            Some(&vec!["HIT".to_string()])
801        );
802    }
803
804    #[tokio::test]
805    async fn test_non_cacheable_method_passes_through() {
806        let r = PluginResources::empty();
807        let l = lookup(&r);
808        let s = store(&r);
809
810        // POST is not in the default cache_method → both phases pass through.
811        let mut resp = ctx("POST");
812        resp.response.status_code = 200;
813        resp.response.body = Bytes::from_static(b"not-cached");
814        s.execute(resp).await.unwrap();
815
816        let out = l.execute(ctx("POST")).await.unwrap();
817        assert!(
818            out.port.is_none(),
819            "non-cacheable method must never hit the cache"
820        );
821    }
822
823    /// A backend that cannot answer. Stands in for a redis outage, so the
824    /// degradation path is testable without a live store.
825    struct BrokenCache;
826
827    #[async_trait::async_trait]
828    impl crate::traffic::ResponseCache for BrokenCache {
829        async fn get(
830            &self,
831            _key: &str,
832        ) -> Result<Option<crate::traffic::CachedResponse>, crate::traffic::cache::CacheError>
833        {
834            Err(crate::traffic::cache::CacheError(
835                "backend down".to_string(),
836            ))
837        }
838        async fn put(
839            &self,
840            _key: &str,
841            _entry: &crate::traffic::CachedResponse,
842            _ttl: std::time::Duration,
843        ) -> Result<(), crate::traffic::cache::CacheError> {
844            Err(crate::traffic::cache::CacheError(
845                "backend down".to_string(),
846            ))
847        }
848        async fn purge(&self, _id: &str) -> Result<u64, crate::traffic::cache::CacheError> {
849            Err(crate::traffic::cache::CacheError(
850                "backend down".to_string(),
851            ))
852        }
853    }
854
855    /// The load-bearing behaviour: an outage costs latency, not availability.
856    /// A lookup against a dead backend must leave through `success` (on to the
857    /// upstream), not `error` and not `hit`.
858    #[tokio::test]
859    async fn test_a_failing_backend_is_a_miss_not_an_error() {
860        let plugin = lookup_plugin_with_cache(Arc::new(BrokenCache));
861        let out = plugin
862            .execute(test_context())
863            .await
864            .expect("a cache outage must not fail the request");
865        assert_eq!(
866            out.port, None,
867            "a miss continues to the upstream on `success`"
868        );
869    }
870
871    /// ...but it must not be silent, or a fully-degraded cache is
872    /// indistinguishable from a working one.
873    #[tokio::test]
874    async fn test_a_failing_backend_increments_the_error_counter() {
875        let metrics = test_metrics();
876        let plugin = lookup_plugin_with_cache_and_metrics(Arc::new(BrokenCache), metrics.clone());
877        plugin.execute(test_context()).await.unwrap();
878
879        assert_eq!(
880            metrics
881                .cache_events
882                .with_label_values(&["local", "", "error"])
883                .get(),
884            1,
885            "a backend error must be visible in metrics"
886        );
887    }
888
889    /// A write route that ends in a purge half clears what the read route's
890    /// pair cached, so the next read misses instead of serving the old value.
891    #[tokio::test]
892    async fn test_purge_phase_clears_its_pair() {
893        let cache = Arc::new(crate::traffic::LocalResponseCache::default());
894        let entry = crate::traffic::CachedResponse {
895            status: 200,
896            headers: HashMap::new(),
897            body: bytes::Bytes::from_static(b"x"),
898        };
899        cache
900            .put("cat\u{1}/x", &entry, std::time::Duration::from_secs(60))
901            .await
902            .unwrap();
903        let plugin = purge_plugin_with_cache(cache.clone());
904
905        let out = plugin.execute(test_context()).await.unwrap();
906
907        assert_eq!(out.port, None, "a completed purge continues on success");
908        assert!(cache.get("cat\u{1}/x").await.unwrap().is_none());
909    }
910
911    /// Reads degrade, purges report. A purge that could not reach its backend
912    /// leaves the cache stale in exactly the situation invalidation exists to
913    /// fix, so it takes the error port rather than continuing silently.
914    #[tokio::test]
915    async fn test_purge_phase_against_a_failing_backend_exits_error() {
916        let plugin = purge_plugin_with_cache(Arc::new(BrokenCache));
917        let result = plugin.execute(test_context()).await;
918        assert!(result.is_err(), "a failed purge must not read as success");
919        assert_eq!(result.unwrap_err().error.code, "CACHE_PURGE_FAILED");
920    }
921
922    /// The documented use case is a purge node on a write route -- POST, PUT,
923    /// DELETE -- none of which are in the default `cache_method` (GET/HEAD).
924    /// A purge that only fires for cacheable methods would silently no-op on
925    /// every write it was placed there to invalidate: exactly the silent
926    /// no-op "reads degrade, purges report" forbids.
927    #[tokio::test]
928    async fn test_purge_phase_fires_on_a_write_method_the_cache_would_ignore() {
929        let cache = Arc::new(crate::traffic::LocalResponseCache::default());
930        let entry = crate::traffic::CachedResponse {
931            status: 200,
932            headers: HashMap::new(),
933            body: bytes::Bytes::from_static(b"x"),
934        };
935        cache
936            .put("cat\u{1}/x", &entry, std::time::Duration::from_secs(60))
937            .await
938            .unwrap();
939        let plugin = purge_plugin_with_cache(cache.clone());
940
941        let out = plugin.execute(ctx("POST")).await.unwrap();
942
943        assert_eq!(out.port, None, "a completed purge continues on success");
944        assert!(
945            cache.get("cat\u{1}/x").await.unwrap().is_none(),
946            "a purge on a write method must still clear its pair"
947        );
948    }
949
950    /// One large response must not be able to fill a store that sessions,
951    /// counters and ACME also live in.
952    #[tokio::test]
953    async fn test_a_response_over_max_object_bytes_is_not_cached() {
954        let cache = Arc::new(crate::traffic::LocalResponseCache::default());
955        let plugin = store_plugin_with_cache_and_limit(cache.clone(), 16);
956
957        let mut ctx = test_context();
958        ctx.response.status_code = 200;
959        ctx.response.body = bytes::Bytes::from(vec![b'x'; 64]);
960        plugin.execute(ctx).await.unwrap();
961
962        assert_eq!(cache.len(), 0, "an oversized response must not be stored");
963    }
964
965    #[tokio::test]
966    async fn test_a_response_within_max_object_bytes_is_cached() {
967        let cache = Arc::new(crate::traffic::LocalResponseCache::default());
968        let plugin = store_plugin_with_cache_and_limit(cache.clone(), 1024);
969
970        let mut ctx = test_context();
971        ctx.response.status_code = 200;
972        ctx.response.body = bytes::Bytes::from_static(b"small");
973        plugin.execute(ctx).await.unwrap();
974
975        assert_eq!(cache.len(), 1);
976    }
977
978    /// `max_object_bytes` must come from the node's own config, not only from
979    /// a value a test sets directly on the struct. The other two
980    /// `max_object_bytes` tests build the plugin with
981    /// `store_plugin_with_cache_and_limit`, which sets the field after
982    /// construction and so never exercises `from_config`'s
983    /// `config.get("max_object_bytes")` read — renaming or dropping that key
984    /// would leave every deployment silently back on the 1 MiB default and
985    /// this suite would not notice. This test goes through `from_config`
986    /// instead, with a limit (16 bytes) far below the default, so only the
987    /// configured value — not the constructor default — can explain a miss.
988    #[tokio::test]
989    async fn test_max_object_bytes_is_read_from_node_config() {
990        let r = PluginResources::empty();
991        let plugin = ProxyCachePlugin::from_config(
992            &cfg(&[
993                ("phase", serde_json::json!("store")),
994                ("id", serde_json::json!("cfgtest")),
995                ("max_object_bytes", serde_json::json!(16)),
996            ]),
997            &r,
998        )
999        .unwrap();
1000
1001        let mut oversized = test_context();
1002        oversized.response.status_code = 200;
1003        oversized.response.body = bytes::Bytes::from(vec![b'x'; 64]);
1004        plugin.execute(oversized).await.unwrap();
1005        assert_eq!(
1006            r.traffic.cache.len(),
1007            0,
1008            "a response over the configured max_object_bytes must not be stored"
1009        );
1010
1011        let mut small = test_context();
1012        small.response.status_code = 200;
1013        small.response.body = bytes::Bytes::from_static(b"tiny");
1014        plugin.execute(small).await.unwrap();
1015        assert_eq!(
1016            r.traffic.cache.len(),
1017            1,
1018            "a response under the configured max_object_bytes must still be stored"
1019        );
1020    }
1021
1022    /// Spec §7.1/§8: skipping an oversized response must be metered, so a
1023    /// route that mysteriously never caches is explicable rather than
1024    /// mysterious. Asserted nowhere before this test — a mutation that
1025    /// deleted the `record("too_large")` call passed the whole suite.
1026    #[tokio::test]
1027    async fn test_an_oversized_response_increments_the_too_large_counter() {
1028        let metrics = test_metrics();
1029        let r = PluginResources::new(Some(metrics.clone()));
1030        let plugin = ProxyCachePlugin::from_config(
1031            &cfg(&[
1032                ("phase", serde_json::json!("store")),
1033                ("id", serde_json::json!("toolarge")),
1034                ("max_object_bytes", serde_json::json!(16)),
1035            ]),
1036            &r,
1037        )
1038        .unwrap();
1039
1040        let mut ctx = test_context();
1041        ctx.response.status_code = 200; // cacheable status
1042        ctx.response.body = bytes::Bytes::from(vec![b'x'; 64]);
1043        plugin.execute(ctx).await.unwrap();
1044
1045        assert_eq!(
1046            metrics
1047                .cache_events
1048                .with_label_values(&["local", "", "too_large"])
1049                .get(),
1050            1,
1051            "an oversized response that would otherwise have been cached must be metered"
1052        );
1053    }
1054
1055    /// A response whose status was never going to be cached must not inflate
1056    /// `too_large`, even if it is also oversized — that counter exists to
1057    /// show what the *size limit* excluded, not every large response that
1058    /// passes through the store node.
1059    #[tokio::test]
1060    async fn test_too_large_is_not_counted_for_a_non_cacheable_status() {
1061        let metrics = test_metrics();
1062        let r = PluginResources::new(Some(metrics.clone()));
1063        let plugin = ProxyCachePlugin::from_config(
1064            &cfg(&[
1065                ("phase", serde_json::json!("store")),
1066                ("id", serde_json::json!("toolarge2")),
1067                ("max_object_bytes", serde_json::json!(16)),
1068            ]),
1069            &r,
1070        )
1071        .unwrap();
1072
1073        let mut ctx = test_context();
1074        ctx.response.status_code = 500; // not in the default cache_http_statuses
1075        ctx.response.body = bytes::Bytes::from(vec![b'x'; 64]);
1076        plugin.execute(ctx).await.unwrap();
1077
1078        assert_eq!(
1079            metrics
1080                .cache_events
1081                .with_label_values(&["local", "", "too_large"])
1082                .get(),
1083            0,
1084            "a response that was never cacheable must not count against the size limit"
1085        );
1086    }
1087}