1use 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
49const CACHE_STATUS_HEADER: &str = "featherbit-cache-status";
51const HIDDEN_HEADERS: &[&str] = &["cache-control", "expires"];
53
54#[cfg(feature = "redis-store")]
57const SUPPORTED_POLICIES: &str = "local, redis";
58#[cfg(not(feature = "redis-store"))]
59const SUPPORTED_POLICIES: &str = "local";
60
61#[derive(Debug, Clone, Copy, PartialEq)]
63enum Role {
64 Lookup,
66 Store,
68 Purge,
70}
71
72pub struct ProxyCachePlugin {
77 role: Role,
78 id: String,
80 cache_key: Vec<Template>,
84 cache_ttl: Duration,
86 cache_statuses: Vec<u16>,
88 cache_methods: Vec<String>,
90 hide_cache_headers: bool,
92 cache: Arc<dyn ResponseCache>,
96 backend_label: &'static str,
98 store_label: String,
102 max_object_bytes: usize,
106 resources: Arc<PluginResources>,
108}
109
110impl ProxyCachePlugin {
111 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 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 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 fn method_cacheable(&self, ctx: &Context) -> bool {
321 let method = ctx.request.method.to_uppercase();
322 self.cache_methods.contains(&method)
323 }
324
325 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 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
351fn 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 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 fn reads_response_body(&self) -> bool {
415 !matches!(self.role, Role::Lookup)
416 }
417
418 async fn execute(&self, mut ctx: Context) -> PluginResult {
419 if self.role == Role::Purge {
425 return self.run_purge(ctx).await;
426 }
427
428 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 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 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 Ok(PluginOutput::success(ctx))
470 }
471 Role::Store => {
472 let status = ctx.response.status_code;
473 if self.cache_statuses.contains(&status) {
479 if ctx.response.body.len() > self.max_object_bytes {
480 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 tracing::warn!(key = %key, "proxy-cache store failed: {e}");
494 self.record("error");
495 }
496 }
497 }
498 ctx.response
500 .headers
501 .insert(CACHE_STATUS_HEADER.to_string(), vec!["MISS".to_string()]);
502 Ok(PluginOutput::success(ctx))
503 }
504 Role::Purge => unreachable!("Role::Purge returns early in execute"),
507 }
508 }
509}
510
511impl ProxyCachePlugin {
512 async fn run_purge(&self, ctx: Context) -> PluginResult {
516 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 fn test_context() -> Context {
583 ctx("GET")
584 }
585
586 fn test_metrics() -> Arc<crate::metrics::GatewayMetrics> {
588 Arc::new(crate::metrics::GatewayMetrics::new())
589 }
590
591 #[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 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 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 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 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 #[test]
711 fn test_an_id_containing_the_separator_is_rejected() {
712 let r = PluginResources::empty();
713 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 #[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 assert_eq!(l.derive_key(&ctx("GET")), s.derive_key(&ctx("GET")));
760 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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; 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 #[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; 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}