This commit is contained in:
Mateusz Gruszczyński committed 2026-10-22 14:20:03 +02:00
1 parent 133d2f0f91
commit 3ea00045bf
27 files changed
+446 -136

No files matched your search

+7 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.6",
"version": "0.34.7",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -2061,6 +2061,11 @@
"maxLength": 64
},
"default": []
},
"varnish_compatible": {
"type": "boolean",
"default": true,
"description": "Default true: Varnish-style request cache policy (ignore client Cache-Control/Pragma directives including no-cache, max-age=0, only-if-cached); reject origin no-cache, Surrogate-Control no-store and nonpositive remaining TTL; 10-second stale-while-revalidate grace for eligible stored objects. Existing RustProxy privacy, cookies, Vary, explicit TTL and invalidation options remain in force. False restores strict client-cache directive handling; switching modes invalidates cache; not full VCL emulation."
}
},
"additionalProperties": false
@@ -13683,4 +13688,4 @@
}
},
"tags": []
}
}
+2 -2
View File
@@ -11,7 +11,7 @@ function renderSettings() {
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
if (!settingsDirty.has("cache-form")) {
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-varnish-compatible").checked = c.varnish_compatible !== false; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
@@ -286,7 +286,7 @@ bindSettingsForm("response-form", next => {
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
bindSettingsForm("cache-form", next => {
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, varnish_compatible: $("cache-varnish-compatible").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), http_invalidation_allowed_cidrs: $("cache-http-invalidation-cidrs").value.split(/[\n,]+/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked, expose_only_hits: $("cache-only-hits").checked, via_header: $("cache-via-enabled").checked ? $("cache-via-value").value.trim() : null };
}, "cache");
bindSettingsForm("transform-form", next => {
next.transform = {
+3
View File
@@ -2210,6 +2210,9 @@
Authorization, cookies and private/no-store responses still bypass the cache.
One byte range can be served from a complete cached object;
multipart/unsupported ranges pass through.</p>
<label class="check"><input id="cache-varnish-compatible" type="checkbox" /><span
data-i18n="cacheVarnishCompatible">Varnish-compatible cache policy (recommended; enabled by default)</span></label>
<p class="fine" data-i18n="cacheVarnishCompatibleHelp">When enabled, client Cache-Control and Pragma are ignored for cache lookup: Shift+F5/no-cache/max-age=0 does not replace a fresh HIT or reset its Age. Response private/no-store/no-cache and Surrogate-Control no-store stay uncacheable, responses with no remaining TTL stay out of cache, and eligible expired objects may use up to 10 seconds of background-refresh grace. Existing static-cookie exceptions, Authorization protection, Vary, explicit TTL limits, BAN/PURGE, HOT/COLD, transforms and unsafe-force rules remain independent. Off restores the previous strict client HTTP caching policy. Changing modes invalidates stored objects. This is not full Varnish VCL emulation.</p>
<div class="card soft-card cache-memory-sizing">
<div class="card-heading">
<h3 data-i18n="cacheMemorySizingTitle">Memory sizing</h3>
+3
View File
@@ -6,9 +6,12 @@ mod tests {
h.insert("cache-control", value.parse().unwrap());
h
}
// Legacy-policy regression tests explicitly opt out of the new 0.34.7
// default; varnish-compatible behavior has its own test module.
fn settings() -> Settings {
Settings {
enabled: true,
varnish_compatible: false,
..Default::default()
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ mod dashboard_tests {
}
#[test]
fn bypass_reasons_are_fixed_and_private_inputs_are_not_echoed() {
let settings = Settings::default();
let settings = Settings { varnish_compatible: false, ..Settings::default() };
let mut headers = HeaderMap::new();
headers.insert("cookie", HeaderValue::from_static("session=very-private"));
assert_eq!(
+3 -3
View File
@@ -40,7 +40,7 @@ impl Cache {
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
ticket.freshness_headers(),
);
if state.servable() {
let sequence = store.next_sequence();
@@ -144,7 +144,7 @@ impl Cache {
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
ticket.freshness_headers(),
);
if !state.servable() {
return None;
@@ -217,7 +217,7 @@ impl Cache {
entry.must_revalidate,
entry.proxy_revalidate,
entry.requires_revalidation,
&ticket.request_headers,
ticket.freshness_headers(),
);
if !state.servable() {
return None;
+1
View File
@@ -88,6 +88,7 @@ pub struct Ticket {
target: String,
key: Key,
request_headers: HeaderMap,
freshness_headers: Option<HeaderMap>,
generation: u64,
revision: u64,
scope: String,
+3
View File
@@ -87,6 +87,8 @@ pub enum ColdPreset {
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
/// Ignore client freshness directives on cache lookups, like Varnish builtin VCL.
pub varnish_compatible: bool,
pub memory_preset: Option<MemoryPreset>,
pub max_bytes: usize,
pub max_object_bytes: usize,
@@ -140,6 +142,7 @@ impl Default for Settings {
fn default() -> Self {
Self {
enabled: true,
varnish_compatible: true,
memory_preset: None,
max_bytes: 256 * 1024 * 1024,
max_object_bytes: 16 * 1024 * 1024,
+46 -1
View File
@@ -44,6 +44,21 @@ fn directives(headers: &HeaderMap) -> Option<HashMap<String, Option<String>>> {
}
Some(result)
}
pub fn request_freshness_headers<'a>(
headers: &'a HeaderMap,
settings: &Settings,
) -> std::borrow::Cow<'a, HeaderMap> {
if !settings.varnish_compatible
|| (!headers.contains_key("cache-control") && !headers.contains_key("pragma"))
{
return std::borrow::Cow::Borrowed(headers);
}
let mut freshness = headers.clone();
freshness.remove("cache-control");
freshness.remove("pragma");
std::borrow::Cow::Owned(freshness)
}
pub fn only_if_cached(headers: &HeaderMap) -> bool {
headers
.get_all("cache-control")
@@ -158,6 +173,11 @@ pub fn request_policy(
return RequestPolicy::Bypass(reason);
}
}
// Varnish builtin.vcl performs a cache lookup without interpreting
// client Cache-Control or Pragma. Security bypasses above still apply.
if settings.varnish_compatible {
return RequestPolicy::Use;
}
let Some(control) = directives(headers) else {
return RequestPolicy::Bypass("request_cache_control_invalid");
};
@@ -346,7 +366,24 @@ pub fn varies_on(headers: &HeaderMap, wanted: &str) -> bool {
})
}
pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str {
pub fn surrogate_no_store(headers: &HeaderMap) -> bool {
headers.get_all("surrogate-control").iter().any(|value| {
value.to_str().ok().is_some_and(|text| {
text.split([',', ';']).any(|part| {
part.trim()
.split_once('=')
.map_or(part.trim(), |(key, _)| key.trim())
.eq_ignore_ascii_case("no-store")
})
})
})
}
pub fn response_reason(
status: StatusCode,
headers: &HeaderMap,
settings: &Settings,
) -> &'static str {
if status != StatusCode::OK {
return "response_status";
}
@@ -367,6 +404,14 @@ pub fn response_reason(status: StatusCode, headers: &HeaderMap) -> &'static str
return "response_cache_control";
}
}
if settings.varnish_compatible {
if control.contains_key("no-cache") {
return "response_cache_control";
}
if surrogate_no_store(headers) {
return "response_surrogate_control";
}
}
for value in headers.get_all("vary") {
if value.to_str().ok().is_none_or(|value| {
value.split(',').any(|name| {
+21 -1
View File
@@ -92,6 +92,14 @@ pub fn plan(
{
return None;
}
// Varnish builtin.vcl treats these as uncacheable origin responses
// (hit-for-miss is not implemented by RustProxy). Never weaken private
// or no-store handling even if Surrogate-Control is also present.
if settings.varnish_compatible
&& (control.contains_key("no-cache") || surrogate_no_store(headers))
{
return None;
}
let requires_revalidation = control.contains_key("no-cache");
let must_revalidate = control.contains_key("must-revalidate");
let proxy_revalidate = control.contains_key("proxy-revalidate");
@@ -102,7 +110,7 @@ pub fn plan(
Some(None) => None,
}
};
let stale_while_revalidate = parse_window("stale-while-revalidate")?;
let mut stale_while_revalidate = parse_window("stale-while-revalidate")?;
let stale_if_error = parse_window("stale-if-error")?;
if requires_revalidation
&& !headers.contains_key("etag")
@@ -148,6 +156,18 @@ pub fn plan(
None if requires_revalidation => 0,
None => return None,
};
// Like Varnish's built-in VCL, a backend response with no remaining
// freshness is not a reusable shared object (Varnish stores a hit-for-miss
// marker instead; RustProxy does not implement those markers).
if settings.varnish_compatible && initial_age.as_secs() >= ttl {
return None;
}
// Varnish defaults to ten seconds of grace after a *positive* TTL.
// Keep RustProxy's existing must-revalidate/proxy-revalidate safety checks;
// never turn a no-cache or zero-TTL response into a grace HIT.
if settings.varnish_compatible && stale_while_revalidate == 0 {
stale_while_revalidate = 10;
}
if !requires_revalidation
&& initial_age.as_secs()
>= retention_deadline(
+3
View File
@@ -18,3 +18,6 @@ include!("invalidation/http_invalidation_tests.rs");
#[cfg(test)]
include!("lookup/hot_hit_034_tests.rs");
#[cfg(test)]
include!("varnish_compatible_tests.rs");
+22
View File
@@ -101,6 +101,7 @@ impl Cache {
target: target.to_owned(),
key,
request_headers: headers.clone(),
freshness_headers: None,
generation: store.generation,
revision,
scope: scope.to_owned(),
@@ -392,3 +393,24 @@ impl Cache {
}
}
}
impl Ticket {
/// In Varnish compatibility mode, request cache directives do not constrain
/// freshness. Keep original headers for Vary and downstream conditionals.
pub fn with_varnish_compatible(mut self, compatible: bool) -> Self {
if compatible
&& (self.request_headers.contains_key("cache-control")
|| self.request_headers.contains_key("pragma"))
{
let mut freshness = self.request_headers.clone();
freshness.remove("cache-control");
freshness.remove("pragma");
self.freshness_headers = Some(freshness);
}
self
}
fn freshness_headers(&self) -> &HeaderMap {
self.freshness_headers.as_ref().unwrap_or(&self.request_headers)
}
}
+158
View File
@@ -0,0 +1,158 @@
mod varnish_compatible_tests {
use super::*;
use http_body_util::BodyExt;
fn headers(cache_control: &str) -> HeaderMap {
let mut h = HeaderMap::new();
h.insert("cache-control", HeaderValue::from_str(cache_control).unwrap());
h
}
#[test]
fn default_and_legacy_serialization_are_explicit() {
assert!(Settings::default().varnish_compatible);
let legacy: Settings = serde_json::from_str(r#"{"enabled":true}"#).unwrap();
assert!(legacy.varnish_compatible);
let strict: Settings = serde_json::from_str(r#"{"varnish_compatible":false}"#).unwrap();
assert!(!strict.varnish_compatible);
let enabled = serde_json::to_value(&legacy).unwrap();
assert_eq!(enabled["varnish_compatible"], true);
}
#[test]
fn client_freshness_is_ignored_only_in_varnish_mode() {
let mut compatible = Settings::default();
let directives = [
"no-cache", "no-store", "max-age=0", "max-age=1",
"min-fresh=600", "max-stale=9999", "only-if-cached", "invalid directive",
];
for value in directives {
let h = headers(value);
assert_eq!(
request_policy(&Method::GET, &h, true, "/style.css", false, &compatible),
RequestPolicy::Use,
"varnish mode should ignore client directive: {value}"
);
let freshness = request_freshness_headers(&h, &compatible);
assert!(!freshness.contains_key("cache-control"));
assert!(h.contains_key("cache-control"), "do not mutate origin request headers");
}
let mut pragma = HeaderMap::new();
pragma.insert("pragma", HeaderValue::from_static("no-cache"));
assert_eq!(request_policy(&Method::GET, &pragma, true, "/", false, &compatible), RequestPolicy::Use);
assert!(!request_freshness_headers(&pragma, &compatible).contains_key("pragma"));
compatible.varnish_compatible = false;
assert_eq!(request_policy(&Method::GET, &headers("no-cache"), true, "/", false, &compatible), RequestPolicy::Revalidate("request_no_cache"));
assert_eq!(request_policy(&Method::GET, &headers("no-store"), true, "/", false, &compatible), RequestPolicy::Bypass("request_no_store"));
assert!(only_if_cached(&headers("only-if-cached")), "strict-only 504 guard still recognizes directive");
}
#[test]
fn vcl_style_request_lookup_preserves_privacy_and_local_rules() {
let mut settings = Settings::default();
for (name, value) in [
("authorization", "Bearer private"),
("cookie", "session=private"),
("if-match", "\"etag\""),
("upgrade", "websocket"),
] {
let mut h = headers("no-cache");
h.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), HeaderValue::from_str(value).unwrap());
assert!(matches!(request_policy(&Method::GET, &h, true, "/index.html", false, &settings), RequestPolicy::Bypass(_)));
}
settings.bypass_query = true;
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/style.css", true, &settings), RequestPolicy::Bypass("query_excluded"));
settings.bypass_query = false;
settings.bypass_prefixes = vec!["/private".into()];
assert_eq!(request_policy(&Method::GET, &HeaderMap::new(), true, "/private/account", false, &settings), RequestPolicy::Bypass("path_excluded"));
assert_eq!(request_policy(&Method::POST, &HeaderMap::new(), true, "/style.css", false, &settings), RequestPolicy::Bypass("request_method"));
assert!(settings.ignore_cookie_for_path("/style.css"), "existing static cookie feature remains enabled");
assert!(!settings.ignore_cookie_for_path("/index.html"));
}
#[test]
fn backend_response_policy_matches_varnish_without_weakening_privacy() {
let compatible = Settings::default();
let mut strict = compatible.clone();
strict.varnish_compatible = false;
for control in ["private, max-age=60", "no-store, max-age=60"] {
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none());
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_none());
}
let mut no_cache = headers("no-cache, max-age=60");
no_cache.insert("etag", HeaderValue::from_static("\"origin\""));
assert!(plan(StatusCode::OK, &no_cache, &compatible, Duration::ZERO).is_none());
assert!(plan(StatusCode::OK, &no_cache, &strict, Duration::ZERO).is_some());
assert_eq!(response_reason(StatusCode::OK, &no_cache, &compatible), "response_cache_control");
let mut surrogate = headers("public, max-age=60");
surrogate.insert("surrogate-control", HeaderValue::from_static("max-age=120; NO-STORE"));
assert!(plan(StatusCode::OK, &surrogate, &compatible, Duration::ZERO).is_none());
assert_eq!(response_reason(StatusCode::OK, &surrogate, &compatible), "response_surrogate_control");
assert!(plan(StatusCode::OK, &surrogate, &strict, Duration::ZERO).is_some());
for control in ["max-age=0", "max-age=60, s-maxage=0"] {
assert!(plan(StatusCode::OK, &headers(control), &compatible, Duration::ZERO).is_none());
assert!(plan(StatusCode::OK, &headers(control), &strict, Duration::ZERO).is_some());
}
let mut cookie = headers("max-age=60");
cookie.insert("set-cookie", HeaderValue::from_static("session=private"));
assert!(plan(StatusCode::OK, &cookie, &compatible, Duration::ZERO).is_none());
let mut vary = headers("max-age=60");
vary.insert("vary", HeaderValue::from_static("*"));
assert!(plan(StatusCode::OK, &vary, &compatible, Duration::ZERO).is_none());
}
#[tokio::test]
async fn varnish_style_hard_reload_keeps_hot_hit_and_age_without_origin_validator() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("public css"));
*response.headers_mut() = headers("public, max-age=600");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(45);
}
}
let mut request = headers("no-cache, max-age=0, min-fresh=900");
request.insert("pragma", HeaderValue::from_static("no-cache"));
let compatibility_ticket = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap().with_varnish_compatible(true);
let first = cache.get(&compatibility_ticket).expect("hard reload should keep a fresh HIT");
assert!(first.response.headers().get("age").unwrap().to_str().unwrap().parse::<u64>().unwrap() >= 45);
assert_eq!(first.hits, 1);
let again = cache.get(&compatibility_ticket).unwrap();
assert_eq!(again.hits, 2, "hard reload must not reset HIT counter");
assert_eq!(cache.view().entries, 1);
let strict = cache.ticket(1, "https", "example.test", "app", "/style.css", &request).unwrap();
assert!(cache.get(&strict).is_none(), "strict max-age=0 requires revalidation");
}
#[tokio::test]
async fn ten_second_grace_and_no_unconditional_stale_outside_window() {
let cache = Cache::new(1);
let settings = Settings::default();
let ticket = cache.ticket(1, "https", "example.test", "app", "/asset.js", &HeaderMap::new()).unwrap();
let mut response = Response::new(Body::from("js"));
*response.headers_mut() = headers("public, max-age=60");
let plan = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
assert_eq!(plan.stale_while_revalidate, 10);
cache.wrap(response, ticket.clone(), plan, &settings).into_body().collect().await.unwrap();
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(65);
}
}
let hit = cache.get(&ticket).expect("grace HIT");
assert!(hit.stale && hit.background_revalidate);
{
let mut store = cache.store.lock().unwrap();
for entry in store.entries.values_mut() {
entry.received = Instant::now() - Duration::from_secs(75);
}
}
assert!(cache.get(&ticket).is_none(), "expired beyond grace does not produce HIT");
}
}
+8 -4
View File
@@ -178,7 +178,8 @@ let original_policy = cache_plan.request_policy(
);
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
cache_settings.active_for(route.cache) && !cache_settings.varnish_compatible
&& crate::cache::only_if_cached(request.headers());
let origin_suffix = match route.rewritten_path(&path) {
Ok(None) => public_suffix.clone(),
Ok(Some(rewritten)) => {
@@ -296,7 +297,7 @@ let cache_ticket = if effective_cacheable {
cache_route,
&public_suffix,
key_headers,
)
).map(|ticket| ticket.with_varnish_compatible(cache_settings.varnish_compatible))
} else {
None
};
@@ -308,14 +309,17 @@ let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
} else {
None
};
let freshness_headers = crate::cache::request_freshness_headers(
&client_conditionals, cache_settings,
);
let freshness_revalidation_reason = cached_revalidation
.as_ref()
.and_then(|cached| cached.request_freshness_revalidation_reason(&client_conditionals));
.and_then(|cached| cached.request_freshness_revalidation_reason(&freshness_headers));
let pending_revalidation = revalidation_reason.is_some()
|| freshness_revalidation_reason.is_some()
|| cached_revalidation
.as_ref()
.is_some_and(|cached| !cached.reusable_for_request(&client_conditionals));
.is_some_and(|cached| !cached.reusable_for_request(&freshness_headers));
let miss_recorded = false;
(
backend, backend_lease, affinity_cookie, admin_control_plane, cache_settings,
+1 -1
View File
@@ -130,7 +130,7 @@ let origin_reason = (cache_ticket.is_some() && origin_plan.is_none()).then(|| {
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
"response_vary_cookie"
} else {
crate::cache::response_reason(response.status(), response.headers())
crate::cache::response_reason(response.status(), response.headers(), cache_settings)
}
});
let origin_no_transform = crate::transform::no_transform(response.headers());
+7 -2
View File
@@ -64,6 +64,7 @@
state.cache.revision(), scope, context.scheme,
original_host.to_str().unwrap_or(""), &route.id, &public_suffix,
key_headers.as_ref().unwrap_or(&request_headers))
.map(|ticket| ticket.with_varnish_compatible(cache_settings.varnish_compatible))
} else { None };
let range = cache_plan::range_selection(eligible, &request_method, &request_headers);
let if_range = if eligible { request_headers.get("if-range").cloned() } else { None };
@@ -71,9 +72,12 @@
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
state.cache.revalidation_tiered(ticket).await
} else { None };
let freshness_headers = crate::cache::request_freshness_headers(
&request_headers, cache_settings,
);
let needs_validation = policy.revalidation_reason().is_some()
|| cached_revalidation.as_ref().is_some_and(|cached|
!cached.reusable_for_request(&request_headers));
!cached.reusable_for_request(&freshness_headers));
if let Some(ticket) = &cache_ticket {
if !needs_validation {
if let Some(mut hit) = state.cache.get_tiered(ticket, cache_settings).await {
@@ -109,7 +113,8 @@
}
}
}
if cache_ticket.is_some() && crate::cache::only_if_cached(&request_headers) {
if cache_ticket.is_some() && !cache_settings.varnish_compatible
&& crate::cache::only_if_cached(&request_headers) {
return error(StatusCode::GATEWAY_TIMEOUT,
"RustProxy: only-if-cached request has no usable Web Server representation\n");
}