From a4042d6439c522f95b5707bb955a9292541050bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sun, 25 Oct 2026 10:15:26 +0100 Subject: [PATCH] v0.34.12 --- SHA256SUMS | 30 ++++---- src/cache/dashboard_tests.rs | 74 ++++++++++++++++++ src/cache/lookup/hot.rs | 4 + src/cache/lookup/tiered.rs | 1 + src/cache/model/entry.rs | 88 +++++++++++++++++++--- src/cache/model/maintenance.rs | 15 ++++ src/cache/range.rs | 1 + src/cache/tickets.rs | 1 + src/proxy/forward/cache_fast_path_tests.rs | 85 ++++++++++++++++++++- src/proxy/forward/cache_lookup.rs | 38 ++++++---- src/proxy/forward/response.rs | 4 +- src/proxy/forward/success_finish.rs | 28 +++++-- src/proxy/forward/success_prepare.rs | 1 + src/proxy/forward/web_source.rs | 21 ++++-- src/proxy/web_cache_revalidation/mod.rs | 10 ++- src/proxy/web_cache_revalidation/tests.rs | 16 ++++ 16 files changed, 362 insertions(+), 55 deletions(-) diff --git a/SHA256SUMS b/SHA256SUMS index 37c8981..3609aa2 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -277,7 +277,7 @@ e80f84449412c4ae7bc753672a554160519bf1f186b5707252c3286cbe13f02e src/cache/cold 83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs a36a4a255a0fd2c38449bbc462b9c0e21488eae108c70326ab2a0e4c7cee0f97 src/cache/collapse.rs 346fdb40d85627b583a01fa17c65e45c822dc7216198cdeadbb3d07904286fa3 src/cache/dashboard.rs -7ce2e4cd201813cef10db0cd791d39deb8f2dec7abed934dd0878d4a47da2d7d src/cache/dashboard_tests.rs +54785b64ab4a7b0382b84b4a313d1996c4b6986d69f1e598293f29122afbad33 src/cache/dashboard_tests.rs 8b5a8c04a8ee115497635d2faf08eab5ca4db4225c6c70110675685346f9c0e5 src/cache/fill.rs 675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs @@ -285,27 +285,27 @@ bbd9f372648d89b5e14d5b58029582d37f66a89752caf2c14eb1cbf8f24bb7a0 src/cache/inva 6223eaea3f061b111330cc26b28d85b9b239bef30328025f2ef337291e22a445 src/cache/invalidation/invalidate.rs 9bae8be41d1e6b30cda51feff99fd77738940fa975bdd4ece7da75d936f07b47 src/cache/lifecycle.rs bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs -9cfb83f414bdf4ccbedc3daf2eeeea4cb2ef83ab7c6aaef6063a331c9fbf5608 src/cache/lookup/hot.rs +13ecc0fec5f9ea4c555bc23e7ad0aaec2ba10032e8f5fd2729675316edb28487 src/cache/lookup/hot.rs 4148106b9baf70e41eeba24233b1edbe8dd6f927c11f2c7647e89fbbfb60555e src/cache/lookup/hot_hit_034_tests.rs e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs -4c75c0d574b5841639068dc6890c6e06892e53cfcb080fffebd0ef4e5284d726 src/cache/lookup/tiered.rs +6943a1856da7aaffc9af335d0af7bc88cbb7a8ba9f67a83c91e4500ad431ce56 src/cache/lookup/tiered.rs 639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.rs 0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs c409174a9aaf20c69dc7b329108d1c26b5672a7341236cf639480df48800d262 src/cache/model/cache.rs 0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs -cf3c64de61e997da976b3cb54ac09b346679ccf53597900c7b2ea19a3e507b73 src/cache/model/entry.rs +9f3143d99ee7e11abdb625cd8deff0af3ede8af716ea99b754e92803d1150bf7 src/cache/model/entry.rs ff1c3a1a3bc0d26218dcc943ef951c2a212c180bbf6e4bf1c542ffbf12a9f150 src/cache/model/http_invalidation.rs -dc1153685671be70c28bef814d72d8ef09afe070598f528ea62260c54d43b26f src/cache/model/maintenance.rs +81938ee9d96891a6e1c81ac1117c67bd4ce4a1530684eab84521b3de1a503dff src/cache/model/maintenance.rs 19aaf66abe2ad30b973131a020b7ca8ec6d0646a51df5c604c5f8914db898416 src/cache/model/settings.rs 395a82091ae520b78fa606553a0ec590ffb14540b83234cef5bbdcd2e57d39d5 src/cache/model/views.rs e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs 896b1869f94973b30a0de0faa62a2c89fbc8991b02de0e5988ab4ce1f77b392d src/cache/policy/request.rs 9dd790a35dbc15df8465664ed9fff296fe22847371d667aefe320504ab952b2b src/cache/policy/response.rs e59efd322f518ebd7b5b154115188f382c2cf7882ca0a401736ba5d685981ea5 src/cache/policy/scope_tests.rs -2b556ac9179b8a92de368f5e907e4114c2fc21a89155fe2a05665c45c08b3883 src/cache/range.rs +4179c2c1367a9c8ea8432bd5815b7e7afbca820d7589742b3ad4775c0cc8e0bc src/cache/range.rs e1d3f0512d6290b0ac4998f47398a0a7c404db249c5bbdbc4ad4767071013a72 src/cache/stats.rs 8a300f5dad44815401eb23f4f5a157893761852ba4f3005041e0eb3f16892ce3 src/cache/tests.rs -895b589b9866fafafce9f464c4a0d3d2ced55989a1d909bc152ab1529c2a1550 src/cache/tickets.rs +8d9532ea1f30ec431195698a9aba82f2d31083d4b4ee98cb8d9eb47b2b01c7b9 src/cache/tickets.rs b7c063ed4c8ca96714e1dc5d279f38fc521da2132faad7bd4f40311352a71e7c src/cache/v022_cache_tests.rs 452ff2f162f70c3e4d9259de4b13a7751874742c6922c5e0e0196eeadee4ccaa src/cache/varnish_compatible_tests.rs 5a8022f6f4721f9fabdc715e01127d9299d47ee9981a79d26df1981448487d20 src/compression/mod.rs @@ -391,15 +391,15 @@ d134b0eb617f82f9971b3f3461379ea37c57bd0079bf54748d0c434b2ef1b475 src/proxy/cach a03b1735daa8fa8e40cf5bd9c6e200d211baf19c30f62522d4237ed56642bce7 src/proxy/cache_plan/mod.rs 3404b03096862de1050088f62cdd811ea1ab61d191f902501173107f81f23b9b src/proxy/cache_plan/tests.rs a0b9e2e9ab0bc89c7b64bd19365198234b1c5c13f455ea47be122a5660639b17 src/proxy/error_fallback.rs -e466e765ba49c36af75042a078fc5dec3ac803b180ae240057ae832b9896ac53 src/proxy/forward/cache_fast_path_tests.rs -e8ef0466a11eb72724d640adbd802915461c776d0983509625b3e14e42d2c2e9 src/proxy/forward/cache_lookup.rs +3efd124f2c27ac2d5b6c6c0ab6a5b91b7b18ce2f3bf097a00fca5475b05e36c0 src/proxy/forward/cache_fast_path_tests.rs +f7903200756dc6393392560656b23364f0d444d6357d264713b17c63fd8b636b src/proxy/forward/cache_lookup.rs 287012c81144d8f15af4a148ce3ca911d431bc59adff44e697567561aa372bb3 src/proxy/forward/h3_navigation_tests.rs -ce97cfcb07d90415937f0520ee7175502b22e60694c1ea8e3d2b47678d190e5f src/proxy/forward/response.rs +a6580dc359d1f377a48ccc1a902056dc5c2ef86c89180b9b0995068d9817ae8a src/proxy/forward/response.rs 1a7322aadbe90e2a6940a27882c13e274b1c92f14de3ebad717054a9614d4ba8 src/proxy/forward/route_cache.rs -ac449238bdc9b3c2c693b773c023d34c0844a40d9472154e4526a46540004a35 src/proxy/forward/success_finish.rs -aa30766a5d584c2143488f16caef19c1d369a2c7c00b5ff960eaf2ccf4045c31 src/proxy/forward/success_prepare.rs +0e6e288fb8a0575aaa4e3bcb5c47ed5412a584386a2b00fb25db8428d652cbf3 src/proxy/forward/success_finish.rs +744daef016d4a6c821ab12eeedaa2f29e2de72e2ac9dae16481d4f43476cb713 src/proxy/forward/success_prepare.rs e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs -1bf132dee1d89d1e9b8eeb0c5b6f4fcc9e2a1d741b73876300221d0264696aa5 src/proxy/forward/web_source.rs +fcac33e29f518964e8a8d90f44f58a984cf610a5960150a0149c54425491ea86 src/proxy/forward/web_source.rs 3340583d395fcc859cf3421f4e33cf8347915ee4c468f7d0e4abebce8156fdca src/proxy/forward_entry.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs 684ea1e2a7591a17e3c633576fc66e2a30d6636738fbce61dab5af44f2c51a67 src/proxy/headers/authority_tests.rs @@ -412,8 +412,8 @@ de1763b89d98607e20f0f6e3c9b546483927cbe5450efc2e5634fe691fd52ad6 src/proxy/requ b9a2307c6aa7f16613d935508a61574dfee964020b1b5be86293df2d684615d8 src/proxy/request_id/tests.rs 580fefdc7dfb27da73f86f4d2067d46a1f856d2f398b0598d37a743e880d3dac src/proxy/types.rs 37b20f962563babd3602d21ed6326946635281c0b0d97ca59f4e33de170ae93f src/proxy/upstream_helpers.rs -e061db49b6ee6074621a539cc2b8d13e16d499fea2ec7b3086aec5ce4a8f4e99 src/proxy/web_cache_revalidation/mod.rs -b9e2c34cfe0ac8627aca64b234e42024103b1bad6efc2f1bcc4347d929aee8f3 src/proxy/web_cache_revalidation/tests.rs +56a921ee4f30da154176998a383bcb5a40d3c7784a04829c59b8e6536728e2d4 src/proxy/web_cache_revalidation/mod.rs +fdc589ed3968915558a98a11c7cadc36145b5b0b0dca9393613391990d138fc3 src/proxy/web_cache_revalidation/tests.rs 3d591201595a3b1e928ded20a8736477a47e5acc3bcbde158f7c17547ce155ba src/proxy/websocket.rs f8972bedb1a487fac86177774cd98cf2619fb25af97e57cfac2853db5cbf91a9 src/proxy/websocket/handshake.rs c02d42b5c7eeedbc54c58e98691f7d8e39f8e9be63a48f6d6b4ad6b8ea431b7e src/proxy/websocket/http2_tests.rs diff --git a/src/cache/dashboard_tests.rs b/src/cache/dashboard_tests.rs index fc4d24f..8b44c9a 100644 --- a/src/cache/dashboard_tests.rs +++ b/src/cache/dashboard_tests.rs @@ -409,6 +409,80 @@ mod dashboard_tests { ); } + #[test] + fn rfc9211_method_bypass_and_stale_hit_do_not_misstate_freshness() { + assert_eq!(cache_status_ttl(1, 2), -1); + assert_eq!(cache_status_ttl(10, 10), 0); + assert_eq!(cache_status_ttl(600, 5), 595); + let settings = Settings { + expose_status: true, + expose_cache_status: true, + ..Settings::default() + }; + let mut headers = HeaderMap::new(); + settings.annotate_reason(&mut headers, "BYPASS", 0, 0, "request_method"); + assert_eq!( + headers["cache-status"], + "RustProxy; fwd=method; detail=\"request_method\"", + ); + let mut headers = HeaderMap::new(); + settings.annotate_reason_transformed_freshness( + &mut headers, "HIT", 3, -7, "cache_max_stale", false, + ); + assert_eq!( + headers["cache-status"], + "RustProxy; hit; ttl=-7; detail=\"cache_max_stale\"", + ); + let mut headers = HeaderMap::new(); + settings.annotate_reason_transformed_freshness( + &mut headers, "HIT", 3, -5, "cache_stale_while_revalidate", false, + ); + assert_eq!( + headers["cache-status"], + "RustProxy; hit; ttl=-5; detail=\"cache_stale_while_revalidate\"", + ); + } + + #[test] + fn rfc9211_forwarded_stale_error_and_forced_validation_report_real_hops() { + let settings = Settings { + expose_status: true, + expose_cache_status: true, + ..Settings::default() + }; + let mut headers = HeaderMap::new(); + headers.append("cache-status", HeaderValue::from_static("OriginCache; hit")); + settings.annotate_reason_transformed_forwarded( + &mut headers, "HIT", 3, 0, "cache_stale_if_error", false, + "stale", Some(StatusCode::SERVICE_UNAVAILABLE), + ); + assert_eq!(headers["x-cache"], "HIT"); + let values: Vec<_> = headers.get_all("cache-status").iter() + .map(|value| value.to_str().unwrap()).collect(); + assert_eq!(values, vec![ + "OriginCache; hit", + "RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\"", + ]); + let mut headers = HeaderMap::new(); + settings.annotate_reason_transformed_forwarded( + &mut headers, "HIT", 2, 42, "cache_revalidated", false, + "request", Some(StatusCode::NOT_MODIFIED), + ); + assert_eq!( + headers["cache-status"], + "RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\"", + ); + let mut headers = HeaderMap::new(); + settings.annotate_reason_transformed_forwarded( + &mut headers, "HIT", 2, 0, "cache_stale_if_error", false, + "stale", None, + ); + assert_eq!( + headers["cache-status"], + "RustProxy; fwd=stale; detail=\"cache_stale_if_error\"", + ); + } + #[test] fn cache_domain_metrics_do_not_lock_hot_store_and_preserve_reasons() { let cache = Cache::new(7); diff --git a/src/cache/lookup/hot.rs b/src/cache/lookup/hot.rs index cd1f0ed..d1e0abf 100644 --- a/src/cache/lookup/hot.rs +++ b/src/cache/lookup/hot.rs @@ -52,6 +52,7 @@ impl Cache { transformed, skip_compression, remaining_ttl, + entry_ttl, body_bytes, ) = { let entry = store.entries.get_mut(&storage_key)?; @@ -67,6 +68,7 @@ impl Cache { entry.transformed, entry.skip_compression, entry.ttl.saturating_sub(age), + entry.ttl, entry.body.len(), ) }; @@ -97,6 +99,7 @@ impl Cache { hits, transformed, remaining_ttl, + cache_status_ttl: cache_status_ttl(entry_ttl, age), resident_age_seconds, stale: state.stale(), background_revalidate: state.background_revalidate(), @@ -301,6 +304,7 @@ impl Cache { hits, transformed: entry.transformed, remaining_ttl: entry.ttl.saturating_sub(age), + cache_status_ttl: cache_status_ttl(entry.ttl, age), resident_age_seconds, stale: state.stale(), background_revalidate: state.background_revalidate(), diff --git a/src/cache/lookup/tiered.rs b/src/cache/lookup/tiered.rs index 73296c0..c307b8d 100644 --- a/src/cache/lookup/tiered.rs +++ b/src/cache/lookup/tiered.rs @@ -110,6 +110,7 @@ impl Cache { hits, transformed: cached.transformed, remaining_ttl, + cache_status_ttl: cache_status_ttl(plan.ttl, age), resident_age_seconds: resident_age.as_secs(), stale: false, background_revalidate: false, diff --git a/src/cache/model/entry.rs b/src/cache/model/entry.rs index 9271ab7..b7d2772 100644 --- a/src/cache/model/entry.rs +++ b/src/cache/model/entry.rs @@ -171,7 +171,7 @@ impl Settings { .any(|item| item.eq_ignore_ascii_case(extension)) } pub fn annotate(&self, headers: &mut HeaderMap, outcome: &'static str, hits: u64, ttl: u64) { - self.annotate_internal(headers, outcome, hits, ttl, None, false); + self.annotate_internal(headers, outcome, hits, ttl as i64, None, false, None); } pub fn annotate_reason( @@ -182,7 +182,15 @@ impl Settings { ttl: u64, reason: &'static str, ) { - self.annotate_internal(headers, outcome, hits, ttl, Some(reason), false); + self.annotate_internal( + headers, + outcome, + hits, + ttl as i64, + Some(reason), + false, + None, + ); } pub fn annotate_reason_transformed( @@ -194,7 +202,57 @@ impl Settings { reason: &'static str, transformed: bool, ) { - self.annotate_internal(headers, outcome, hits, ttl, Some(reason), transformed); + self.annotate_internal( + headers, + outcome, + hits, + ttl as i64, + Some(reason), + transformed, + None, + ); + } + + pub fn annotate_reason_transformed_freshness( + &self, + headers: &mut HeaderMap, + outcome: &'static str, + hits: u64, + freshness_ttl: i64, + reason: &'static str, + transformed: bool, + ) { + self.annotate_internal( + headers, + outcome, + hits, + freshness_ttl, + Some(reason), + transformed, + None, + ); + } + + pub fn annotate_reason_transformed_forwarded( + &self, + headers: &mut HeaderMap, + outcome: &'static str, + hits: u64, + ttl: u64, + reason: &'static str, + transformed: bool, + forward: &'static str, + forward_status: Option, + ) { + self.annotate_internal( + headers, + outcome, + hits, + ttl as i64, + Some(reason), + transformed, + Some((forward, forward_status)), + ); } fn annotate_internal( @@ -202,9 +260,10 @@ impl Settings { headers: &mut HeaderMap, outcome: &'static str, hits: u64, - ttl: u64, + ttl: i64, reason: Option<&'static str>, transformed: bool, + forwarded: Option<(&'static str, Option)>, ) { if !self.enabled { return; @@ -245,15 +304,26 @@ impl Settings { ); } if self.expose_cache_status { - let mut value = if matches!( - reason, - Some("cache_revalidated" | "cache_revalidated_uncached") - ) { - "RustProxy; fwd=stale; fwd-status=304".to_owned() + let forwarded = forwarded.or_else(|| { + matches!( + reason, + Some("cache_revalidated" | "cache_revalidated_uncached") + ) + .then_some(("stale", Some(StatusCode::NOT_MODIFIED))) + }); + let mut value = if let Some((why, status)) = forwarded { + let mut value = format!("RustProxy; fwd={why}"); + if let Some(status) = status { + value.push_str(&format!("; fwd-status={}", status.as_u16())); + } + value } else { match outcome { "HIT" => format!("RustProxy; hit; ttl={ttl}"), "MISS" => "RustProxy; fwd=miss".to_owned(), + "BYPASS" if reason == Some("request_method") => { + "RustProxy; fwd=method".to_owned() + } _ => "RustProxy; fwd=bypass".to_owned(), } }; diff --git a/src/cache/model/maintenance.rs b/src/cache/model/maintenance.rs index d47b4d2..0205626 100644 --- a/src/cache/model/maintenance.rs +++ b/src/cache/model/maintenance.rs @@ -126,12 +126,18 @@ impl Drop for ColdReadGuard<'_> { } } } +fn cache_status_ttl(ttl: u64, age: u64) -> i64 { + const MAX: i128 = 999_999_999_999_999; + (i128::from(ttl) - i128::from(age)).clamp(-MAX, MAX) as i64 +} + pub struct Hit { pub response: Response, body: Bytes, pub hits: u64, pub transformed: bool, pub remaining_ttl: u64, + pub cache_status_ttl: i64, pub resident_age_seconds: u64, pub stale: bool, pub background_revalidate: bool, @@ -163,6 +169,15 @@ impl Revalidation { pub fn reusable_without_validation(&self) -> bool { self.reusable_without_validation } + pub fn cache_status_forward_reason(&self, request_forces_validation: bool) -> &'static str { + if self.age >= self.ttl { + "stale" + } else if request_forces_validation && self.reusable_without_validation { + "request" + } else { + "miss" + } + } pub fn headers(&self) -> &HeaderMap { &self.headers } diff --git a/src/cache/range.rs b/src/cache/range.rs index 75c1052..4355869 100644 --- a/src/cache/range.rs +++ b/src/cache/range.rs @@ -173,6 +173,7 @@ impl Revalidation { hits: self.hits, transformed: self.transformed, remaining_ttl: self.ttl.saturating_sub(self.age), + cache_status_ttl: cache_status_ttl(self.ttl, self.age), stale: false, background_revalidate: false, resident_age_seconds: self.resident_age_at_lookup.as_secs(), diff --git a/src/cache/tickets.rs b/src/cache/tickets.rs index 313b42a..43662d2 100644 --- a/src/cache/tickets.rs +++ b/src/cache/tickets.rs @@ -329,6 +329,7 @@ impl Cache { hits, transformed: cached.transformed, remaining_ttl: 0, + cache_status_ttl: cache_status_ttl(cached.ttl, cached.age), resident_age_seconds: cached.resident_age_at_lookup.as_secs(), stale: true, background_revalidate: false, diff --git a/src/proxy/forward/cache_fast_path_tests.rs b/src/proxy/forward/cache_fast_path_tests.rs index 3c4edc3..fe271c0 100644 --- a/src/proxy/forward/cache_fast_path_tests.rs +++ b/src/proxy/forward/cache_fast_path_tests.rs @@ -448,7 +448,7 @@ mod cache_fast_path_tests { let app = Router::new().fallback(move |request: Request| { let count = count.clone(); async move { - count.fetch_add(1, Ordering::Relaxed); + let request_number = count.fetch_add(1, Ordering::Relaxed) + 1; let (parts, body) = request.into_parts(); if parts.uri.path() == "/slow" { tokio::time::sleep(Duration::from_millis(25)).await; @@ -475,8 +475,10 @@ mod cache_fast_path_tests { "/backend-no-cache" | "/cache-headers-revalidate" => { "public, no-cache, max-age=600" } - "/backend-zero-stale" => "public, max-age=0, stale-if-error=120", - "/grace" => "public, max-age=1", + "/backend-zero-stale" | "/stale-then-503" => { + "public, max-age=0, stale-if-error=120" + } + "/grace" | "/stale-no-validator" => "public, max-age=1", _ => "public, max-age=600", }), ); @@ -521,7 +523,13 @@ mod cache_fast_path_tests { response.headers_mut().insert(echoed, value.clone()); } } - if parts + if parts.uri.path() == "/stale-no-validator" { + response.headers_mut().remove("etag"); + } + if parts.uri.path() == "/stale-then-503" && request_number > 1 { + *response.status_mut() = StatusCode::SERVICE_UNAVAILABLE; + *response.body_mut() = Body::from("origin failed"); + } else if parts .headers .get("if-none-match") .is_some_and(|v| v == "\"origin-v1\"") @@ -719,6 +727,69 @@ mod cache_fast_path_tests { fixture.stop().await; } + #[tokio::test] + async fn rfc9211_fresh_forced_validation_is_request_not_stale() { + let origin = origin().await; + let mut config = config_for(origin.addr, true); + config.cache.varnish_compatible = false; + let fixture = Fixture::new(config).await; + let first = fixture.get("/vary", &[]).await; + assert_eq!(first.headers["x-cache"], "MISS"); + assert!(cache_chain(&first.headers).last().unwrap().starts_with("RustProxy; fwd=miss")); + let revalidated = fixture.get("/vary", &[("cache-control", "no-cache")]).await; + assert_eq!(revalidated.status, StatusCode::OK); + assert_eq!(revalidated.headers["x-cache"], "HIT"); + assert_eq!( + cache_chain(&revalidated.headers).last().copied(), + Some("RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\""), + ); + let posted = fixture.send(Method::POST, "/echo", &[], Body::from("posted")).await; + assert_eq!(posted.status, StatusCode::OK); + assert_eq!(posted.body, "posted"); + assert_eq!( + cache_chain(&posted.headers).last().copied(), + Some("RustProxy; fwd=method; detail=\"request_method\""), + ); + fixture.stop().await; + } + + #[tokio::test] + async fn rfc9211_upstream_503_stale_if_error_reports_forwarded_status() { + let origin = origin().await; + let fixture = Fixture::new(config_for(origin.addr, true)).await; + let first = fixture.get("/stale-then-503", &[]).await; + assert_eq!(first.status, StatusCode::OK); + assert_eq!(first.headers["x-cache"], "MISS"); + let stale = fixture.get("/stale-then-503", &[]).await; + assert_eq!(stale.status, StatusCode::OK); + assert_eq!(stale.headers["x-cache"], "HIT"); + assert_eq!( + cache_chain(&stale.headers).last().copied(), + Some("RustProxy; fwd=stale; fwd-status=503; detail=\"cache_stale_if_error\""), + ); + assert_eq!(origin.requests.load(Ordering::Relaxed), 2); + fixture.stop().await; + } + + #[tokio::test] + async fn rfc9211_stale_origin_200_is_fwd_stale_not_fwd_miss() { + let origin = origin().await; + let mut config = config_for(origin.addr, true); + config.cache.varnish_grace_seconds = 0; + let fixture = Fixture::new(config).await; + let first = fixture.get("/stale-no-validator", &[]).await; + assert_eq!(first.headers["x-cache"], "MISS"); + tokio::time::sleep(Duration::from_millis(1200)).await; + let stale = fixture.get("/stale-no-validator", &[]).await; + assert_eq!(stale.status, StatusCode::OK); + assert_eq!(stale.headers["x-cache"], "MISS"); + assert!( + cache_chain(&stale.headers).last().unwrap().starts_with("RustProxy; fwd=stale"), + ); + assert_eq!(origin.requests.load(Ordering::Relaxed), 2); + fixture.stop().await; + } + #[tokio::test] async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() { let origin = origin().await; @@ -869,6 +940,12 @@ mod cache_fast_path_tests { stale.headers["x-cache-reason"], "cache_stale_while_revalidate" ); + let cache_status = cache_chain(&stale.headers); + let last = cache_status.last().unwrap(); + let freshness = last.strip_prefix("RustProxy; hit; ttl=").unwrap() + .split(';').next().unwrap().parse::().unwrap(); + assert!(freshness <= 0, "expired response must not claim positive freshness"); + assert!(last.ends_with("; detail=\"cache_stale_while_revalidate\"")); tokio::time::sleep(Duration::from_millis(100)).await; assert_eq!( origin.requests.load(Ordering::Relaxed), diff --git a/src/proxy/forward/cache_lookup.rs b/src/proxy/forward/cache_lookup.rs index fb0652d..ea745a0 100644 --- a/src/proxy/forward/cache_lookup.rs +++ b/src/proxy/forward/cache_lookup.rs @@ -107,11 +107,11 @@ if let Some(ticket) = &cache_ticket { .lock() .unwrap_or_else(|e| e.into_inner()) = reason; } - cache_settings.annotate_reason_transformed( + cache_settings.annotate_reason_transformed_freshness( hit.response.headers_mut(), "HIT", hit.hits, - hit.remaining_ttl, + hit.cache_status_ttl, reason, hit.transformed, ); @@ -158,20 +158,30 @@ if let Some(ticket) = &cache_ticket { .unwrap_or_else(|e| e.into_inner()) = reason; } } -let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str| -> Option { +let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str, + upstream_status: Option| -> Option { let cached = cached_revalidation.as_ref()?; let mut hit = state.cache.stale_if_error_hit(cached)?; if let crate::cache::RangeSelection::Single(range) = range_selection { hit = hit.apply_range(range, if_range.as_ref()); } - cache_settings.annotate_reason_transformed( - hit.response.headers_mut(), - "HIT", - hit.hits, - 0, - reason, - hit.transformed, - ); + if reason == "cache_stale_if_error" { + cache_settings.annotate_reason_transformed_forwarded( + hit.response.headers_mut(), + "HIT", + hit.hits, + 0, + reason, + hit.transformed, + "stale", + upstream_status, + ); + } else { + cache_settings.annotate_reason_transformed_freshness( + hit.response.headers_mut(), "HIT", hit.hits, hit.cache_status_ttl, + reason, hit.transformed, + ); + } if hit.transformed { state.transform_metrics.record_cache_hit(scope); } @@ -254,11 +264,11 @@ if handshake.is_none() if conditional { hit.response = crate::cache::make_not_modified(hit.response); } - cache_settings.annotate_reason_transformed( + cache_settings.annotate_reason_transformed_freshness( hit.response.headers_mut(), "HIT", hit.hits, - hit.remaining_ttl, + hit.cache_status_ttl, reason, hit.transformed, ); @@ -296,7 +306,7 @@ if handshake.is_none() crate::cache::CollapseTimeoutFallback::StaleIfAvailable ) { if let Some(stale) = - serve_stale("cache_collapse_timeout_stale", Duration::ZERO, &backend.id) + serve_stale("cache_collapse_timeout_stale", Duration::ZERO, &backend.id, None) { return stale; } diff --git a/src/proxy/forward/response.rs b/src/proxy/forward/response.rs index dc4e5ed..f53cd80 100644 --- a/src/proxy/forward/response.rs +++ b/src/proxy/forward/response.rs @@ -65,7 +65,7 @@ }; let stale_class = classify_stale_error(&cause); if cache_settings.allows_stale_error_class(stale_class) { - if let Some(stale) = serve_stale("cache_stale_if_error", elapsed, &active_backend.id) { + if let Some(stale) = serve_stale("cache_stale_if_error", elapsed, &active_backend.id, None) { return stale; } } @@ -112,7 +112,7 @@ .allows_stale_error_class(crate::cache::StaleErrorClass::Timeout) { if let Some(stale) = - serve_stale("cache_stale_if_error", upstream_started.elapsed(), &active_backend.id) + serve_stale("cache_stale_if_error", upstream_started.elapsed(), &active_backend.id, None) { return stale; } diff --git a/src/proxy/forward/success_finish.rs b/src/proxy/forward/success_finish.rs index 6964290..f17952d 100644 --- a/src/proxy/forward/success_finish.rs +++ b/src/proxy/forward/success_finish.rs @@ -21,13 +21,17 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { } let resident_age = hit.resident_age_seconds; let mut response = hit.response; - cache_settings.annotate_reason_transformed( + cache_settings.annotate_reason_transformed_forwarded( response.headers_mut(), "HIT", hit.hits, hit.remaining_ttl, "cache_revalidated", hit.transformed, + cached.cache_status_forward_reason( + revalidation_reason.is_some() || freshness_revalidation_reason.is_some(), + ), + Some(StatusCode::NOT_MODIFIED), ); if hit.transformed { state.transform_metrics.record_cache_hit(scope); @@ -45,13 +49,17 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent { } else { cached.response(merged) }; - cache_settings.annotate_reason_transformed( + cache_settings.annotate_reason_transformed_forwarded( response.headers_mut(), "HIT", 0, 0, "cache_revalidated_uncached", cached.transformed(), + cached.cache_status_forward_reason( + revalidation_reason.is_some() || freshness_revalidation_reason.is_some(), + ), + Some(StatusCode::NOT_MODIFIED), ); if cached.transformed() { state.transform_metrics.record_cache_hit(scope); @@ -182,8 +190,18 @@ let transformed = response .extensions() .get::() .is_some(); -cache_settings.annotate_reason_transformed( - response.headers_mut(), outcome, 0, 0, reason, transformed, -); +if let Some(cached) = &cached_revalidation { + cache_settings.annotate_reason_transformed_forwarded( + response.headers_mut(), outcome, 0, 0, reason, transformed, + cached.cache_status_forward_reason( + revalidation_reason.is_some() || freshness_revalidation_reason.is_some(), + ), + None, + ); +} else { + cache_settings.annotate_reason_transformed( + response.headers_mut(), outcome, 0, 0, reason, transformed, + ); +} response } diff --git a/src/proxy/forward/success_prepare.rs b/src/proxy/forward/success_prepare.rs index 8e43430..fb22b91 100644 --- a/src/proxy/forward/success_prepare.rs +++ b/src/proxy/forward/success_prepare.rs @@ -24,6 +24,7 @@ if cache_settings.allows_stale_error_status(response.status()) { "cache_stale_if_error", upstream_started.elapsed(), &active_backend.id, + Some(response.status()), ) { return stale; } diff --git a/src/proxy/forward/web_source.rs b/src/proxy/forward/web_source.rs index 0e6cf8f..2ba0dbf 100644 --- a/src/proxy/forward/web_source.rs +++ b/src/proxy/forward/web_source.rs @@ -96,8 +96,8 @@ if request_method == Method::HEAD { *response.body_mut() = Body::empty(); } - cache_settings.annotate_reason_transformed(response.headers_mut(), "HIT", hit.hits, - hit.remaining_ttl, "cache_hit", hit.transformed); + cache_settings.annotate_reason_transformed_freshness(response.headers_mut(), "HIT", hit.hits, + hit.cache_status_ttl, "cache_hit", hit.transformed); cache_settings.annotate_resident_age(response.headers_mut(), hit.resident_age_seconds); state.cache.record(scope, &host, "HIT", "cache_hit"); if hit.transformed { state.transform_metrics.record_cache_hit(scope); } @@ -257,9 +257,20 @@ } state.cache.record(scope, &host, outcome, reason); let transformed = response.extensions().get::().is_some(); - cache_settings.annotate_reason_transformed( - response.headers_mut(), outcome, 0, 0, reason, transformed, - ); + if let Some(cached) = &cached_revalidation { + cache_settings.annotate_reason_transformed_forwarded( + response.headers_mut(), outcome, 0, 0, reason, transformed, + cached.cache_status_forward_reason( + policy.revalidation_reason().is_some() + || cached.request_freshness_revalidation_reason(&freshness_headers).is_some(), + ), + None, + ); + } else { + cache_settings.annotate_reason_transformed( + response.headers_mut(), outcome, 0, 0, reason, transformed, + ); + } #[cfg(target_os = "linux")] if !enabled && identity_request && request_method == Method::GET && !transform_settings.is_some_and(|settings| settings.enabled) diff --git a/src/proxy/web_cache_revalidation/mod.rs b/src/proxy/web_cache_revalidation/mod.rs index 56a3581..5780d2a 100644 --- a/src/proxy/web_cache_revalidation/mod.rs +++ b/src/proxy/web_cache_revalidation/mod.rs @@ -129,13 +129,21 @@ pub(super) fn finish_web_revalidation( if *method == Method::HEAD { *response.body_mut() = Body::empty(); } - settings.annotate_reason_transformed( + let request_forces_validation = crate::cache::request_policy( + method, client, true, "", false, settings, + ).revalidation_reason().is_some() + || cached.request_freshness_revalidation_reason( + &crate::cache::request_freshness_headers(client, settings), + ).is_some(); + settings.annotate_reason_transformed_forwarded( response.headers_mut(), "HIT", hits, ttl, reason, transformed, + cached.cache_status_forward_reason(request_forces_validation), + Some(StatusCode::NOT_MODIFIED), ); settings.annotate_resident_age(response.headers_mut(), resident_age); Some(WebRevalidated { diff --git a/src/proxy/web_cache_revalidation/tests.rs b/src/proxy/web_cache_revalidation/tests.rs index 8bc3bae..8fce3e1 100644 --- a/src/proxy/web_cache_revalidation/tests.rs +++ b/src/proxy/web_cache_revalidation/tests.rs @@ -147,6 +147,22 @@ async fn browser_refresh_304_preserves_web_server_hit_counter() { assert_eq!(cache.get_tiered(&ticket, &settings).await.unwrap().hits, 4); } +#[tokio::test] +async fn strict_browser_refresh_uses_request_forward_reason() { + let (_dir, service, cache, ticket, mut settings) = fixture().await; + settings.varnish_compatible = false; + let mut client = HeaderMap::new(); + client.insert("cache-control", HeaderValue::from_static("no-cache")); + let response = validate(&service, &cache, &ticket, &settings, Method::GET, client) + .await.unwrap().response; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()["x-cache"], "HIT"); + assert_eq!( + response.headers()["cache-status"], + "RustProxy; fwd=request; fwd-status=304; detail=\"cache_revalidated\"", + ); +} + #[tokio::test] async fn browser_conditional_refresh_returns_304_without_resetting_hits() { let (_dir, service, cache, ticket, settings) = fixture().await;