This commit is contained in:
Mateusz Gruszczyński committed 2026-10-25 10:15:26 +01:00
1 parent b3dfd570b5
commit a4042d6439
16 files changed
+362 -55

No files matched your search

+74
View File
@@ -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);
+4
View File
@@ -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(),
+1
View File
@@ -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,
+79 -9
View File
@@ -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<StatusCode>,
) {
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<StatusCode>)>,
) {
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(),
}
};
+15
View File
@@ -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
}
+1
View File
@@ -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(),
+1
View File
@@ -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,
+81 -4
View File
@@ -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::<i64>().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),
+24 -14
View File
@@ -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<Response> {
let serve_stale = |reason: &'static str, elapsed: Duration, origin_backend: &str,
upstream_status: Option<StatusCode>| -> Option<Response> {
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;
}
+2 -2
View File
@@ -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;
}
+23 -5
View File
@@ -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::<crate::transform::CachedTransformed>()
.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
}
+1
View File
@@ -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;
}
+16 -5
View File
@@ -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::<crate::transform::Applied>().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)
+9 -1
View File
@@ -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 {
+16
View File
@@ -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;