This commit is contained in:
Mateusz Gruszczyński committed 2026-10-17 11:57:16 +02:00
1 parent 6b937aaf85
commit 87c8393d86
79 files changed
+6727 -297

No files matched your search

+17 -7
View File
@@ -18,7 +18,7 @@ use std::{
};
use tokio::{
sync::OwnedSemaphorePermit,
time::{sleep, Sleep},
time::{sleep_until, Sleep},
};
pub struct Exchange {
@@ -263,7 +263,8 @@ pub struct MeteredBody {
inner: Body,
exchange: Option<Arc<Exchange>>,
direction: Direction,
timer: Pin<Box<Sleep>>,
timer: Option<Pin<Box<Sleep>>>,
deadline: tokio::time::Instant,
idle: Duration,
finished: bool,
}
@@ -274,13 +275,17 @@ impl MeteredBody {
inner,
exchange: Some(exchange),
direction,
timer: Box::pin(sleep(idle)),
// Ready/empty bodies never need a timer allocation. Keep the
// original deadline so a delayed first poll still times out.
timer: None,
deadline: tokio::time::Instant::now() + idle,
idle,
finished,
}
}
fn finish(&mut self) {
self.finished = true;
self.timer.take();
self.exchange.take();
}
}
@@ -318,11 +323,13 @@ impl HttpBody for MeteredBody {
}
}
}
this.timer
.as_mut()
.reset(tokio::time::Instant::now() + this.idle);
if this.inner.is_end_stream() {
this.finish();
} else {
this.deadline = tokio::time::Instant::now() + this.idle;
if let Some(timer) = this.timer.as_mut() {
timer.as_mut().reset(this.deadline);
}
}
Poll::Ready(Some(Ok(frame)))
}
@@ -342,7 +349,10 @@ impl HttpBody for MeteredBody {
Poll::Ready(None)
}
Poll::Pending => {
if this.timer.as_mut().poll(cx).is_ready() {
let timer = this
.timer
.get_or_insert_with(|| Box::pin(sleep_until(this.deadline)));
if timer.as_mut().poll(cx).is_ready() {
if let Some(exchange) = &this.exchange {
exchange.failed.store(true, Ordering::Relaxed);
exchange.backend_error(match this.direction {
+130
View File
@@ -95,4 +95,134 @@ mod tests {
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn ready_and_empty_bodies_keep_accounting_without_timer_allocations() {
for payload in ["", "ready response"] {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let logs = exchange.logs.clone();
let mut body = MeteredBody::new(
Body::from(payload),
exchange,
Direction::Response,
Duration::from_secs(1),
);
assert!(body.timer.is_none());
assert_eq!(slots.available_permits(), 0);
let mut received = Vec::new();
while let Some(frame) = body.frame().await {
received.extend_from_slice(frame.unwrap().data_ref().unwrap());
assert!(body.timer.is_none());
}
assert_eq!(received, payload.as_bytes());
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.completed.load(Ordering::Relaxed), 1);
assert_eq!(
metrics.bytes_out.load(Ordering::Relaxed),
payload.len() as u64
);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 0);
assert_eq!(logs.recent().len(), 1);
}
}
#[tokio::test]
async fn delayed_first_poll_does_not_extend_the_original_idle_deadline() {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let pending = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut body = MeteredBody::new(
Body::from_stream(pending),
exchange,
Direction::Request,
Duration::from_secs(1),
);
// Move the saved initial deadline into the past without wall-clock
// sleeps: the first Pending must create an already-expired timer.
body.deadline = tokio::time::Instant::now() - Duration::from_secs(1);
assert!(body.timer.is_none());
let result = tokio::time::timeout(Duration::from_millis(200), body.frame()).await;
assert!(result.unwrap().unwrap().is_err());
assert!(body.timer.is_none());
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
}
#[tokio::test]
async fn progress_resets_a_lazy_timer_and_trailers_pass_unchanged() {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let (sender, receiver) =
tokio::sync::mpsc::channel::<Result<Frame<Bytes>, std::io::Error>>(4);
let stream = futures_util::stream::unfold(receiver, |mut receiver| async {
receiver.recv().await.map(|frame| (frame, receiver))
});
let mut body = MeteredBody::new(
Body::new(http_body_util::StreamBody::new(stream)),
exchange,
Direction::Response,
Duration::from_secs(5),
);
assert!(futures_util::poll!(body.frame()).is_pending());
assert!(body.timer.is_some());
let original_deadline = body.deadline;
sender
.send(Ok(Frame::data(Bytes::from_static(b"part"))))
.await
.unwrap();
assert_eq!(
body.frame().await.unwrap().unwrap().data_ref().unwrap(),
"part"
);
assert!(body.deadline >= original_deadline);
assert_eq!(body.timer.as_ref().unwrap().deadline(), body.deadline);
let mut trailers = http::HeaderMap::new();
trailers.insert("x-checksum", "complete".parse().unwrap());
sender
.send(Ok(Frame::trailers(trailers.clone())))
.await
.unwrap();
drop(sender);
assert_eq!(
body.frame().await.unwrap().unwrap().trailers_ref(),
Some(&trailers)
);
assert!(body.frame().await.is_none());
assert!(body.timer.is_none());
assert_eq!(metrics.bytes_out.load(Ordering::Relaxed), 4);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 0);
assert_eq!(slots.available_permits(), 1);
}
#[tokio::test]
async fn cancellation_and_read_errors_keep_their_original_outcomes() {
for cancel in [true, false] {
let (exchange, slots) = observation().await;
let metrics = exchange.metrics.clone();
let logs = exchange.logs.clone();
let inner = if cancel {
Body::from_stream(futures_util::stream::pending::<Result<Bytes, std::io::Error>>())
} else {
Body::from_stream(futures_util::stream::once(async {
Err::<Bytes, _>(std::io::Error::other("test read error"))
}))
};
let mut body =
MeteredBody::new(inner, exchange, Direction::Response, Duration::from_secs(1));
if cancel {
assert!(futures_util::poll!(body.frame()).is_pending());
drop(body);
} else {
assert!(body.frame().await.unwrap().is_err());
assert!(body.timer.is_none());
}
assert_eq!(slots.available_permits(), 1);
assert_eq!(metrics.errors.load(Ordering::Relaxed), 1);
assert_eq!(
logs.recent()[0].outcome,
if cancel { "cancelled" } else { "body_error" }
);
}
}
}
+1
View File
@@ -301,6 +301,7 @@ fn apply_backend_diagnostic_values(
backend_header: bool,
backend_timing_header: bool,
) {
response.headers_mut().remove("x-rustproxy-backend");
response.headers_mut().remove("x-rust-proxy-backend");
response.headers_mut().remove("x-rust-proxy-backend-time");
response.headers_mut().remove("x-rustproxy-backend-time");
+93
View File
@@ -0,0 +1,93 @@
//! Request-local cache decisions derived from the already validated route.
//! A disabled plan retains its diagnostic reason without preparing cache-only
//! policy, cookie matching or request metadata. Access and origin policy stay
//! in the common forwarding path and must never depend on this plan.
use crate::cache::{RequestPolicy, Settings};
use http::{HeaderMap, Method};
#[derive(Debug, Clone, Copy)]
pub(super) enum CachePlan {
Disabled(&'static str),
Enabled { force_cache: bool, ignore_cookie: bool },
}
impl CachePlan {
pub(super) fn new(
disabled_reason: Option<&'static str>,
settings: &Settings,
path: &str,
force_match: impl FnOnce() -> bool,
) -> Self {
if let Some(reason) = disabled_reason {
return Self::Disabled(reason);
}
let force_cache = force_match();
Self::Enabled {
force_cache,
ignore_cookie: !force_cache && settings.ignore_cookie_for_path(path),
}
}
pub(super) fn force_cache(self) -> bool {
matches!(self, Self::Enabled { force_cache: true, .. })
}
pub(super) fn ignore_cookie(self) -> bool {
matches!(self, Self::Enabled { ignore_cookie: true, .. })
}
pub(super) fn request_policy(
self,
method: &Method,
headers: &HeaderMap,
body_empty: bool,
path: &str,
has_query: bool,
settings: &Settings,
) -> RequestPolicy {
match self {
Self::Disabled(reason) => RequestPolicy::Bypass(reason),
Self::Enabled { force_cache: true, .. } =>
crate::cache::forced_request_policy(method, headers, body_empty),
Self::Enabled { .. } =>
crate::cache::request_policy(method, headers, body_empty, path, has_query, settings),
}
}
}
/// Header rules may only make a request less cacheable. An original bypass is
/// final, so there is no reason to parse the rewritten headers in that case.
pub(super) fn combine_policy(
original: RequestPolicy,
rewritten: impl FnOnce() -> RequestPolicy,
) -> RequestPolicy {
if matches!(original, RequestPolicy::Bypass(_)) {
return original;
}
match (original, rewritten()) {
(_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
(RequestPolicy::Revalidate(reason), _) => RequestPolicy::Revalidate(reason),
(_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
_ => RequestPolicy::Use,
}
}
/// Origin Range semantics also disable transformation/compression when the
/// cache is off. Only a cache-eligible request needs byte-range parsing.
pub(super) fn range_selection(
cache_eligible: bool,
method: &Method,
headers: &HeaderMap,
) -> crate::cache::RangeSelection {
if cache_eligible {
crate::cache::request_range(method, headers)
} else if headers.contains_key("range") {
crate::cache::RangeSelection::Unsupported
} else {
crate::cache::RangeSelection::None
}
}
#[cfg(test)]
mod tests;
+113
View File
@@ -0,0 +1,113 @@
use super::*;
use crate::cache::RangeSelection;
use http::HeaderValue;
fn settings() -> Settings {
Settings {
enabled: true,
static_ignore_cookies: true,
static_extensions: vec!["css".into()],
..Default::default()
}
}
#[test]
fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_headers() {
let settings = settings();
let mut headers = HeaderMap::new();
headers.insert("authorization", HeaderValue::from_static("private"));
headers.insert("cache-control", HeaderValue::from_static("max-age=not-a-number"));
for reason in [
"cache_disabled", "route_disabled", "admin_control_plane", "basic_auth",
"tinyauth", "sticky_cookie", "request_upgrade",
] {
let plan = CachePlan::new(Some(reason), &settings, "/style.css", || {
panic!("disabled cache must not match force-cache rules")
});
assert!(!plan.force_cache());
assert!(!plan.ignore_cookie());
let policy = plan.request_policy(&Method::GET, &headers, true, "/style.css", false, &settings);
assert_eq!(policy, RequestPolicy::Bypass(reason));
assert_eq!(combine_policy(policy, || panic!("disabled cache must not parse post-rule policy")), policy);
}
}
#[test]
fn cache_on_keeps_all_existing_request_policy_outcomes() {
let settings = settings();
for forced in [false, true] {
let plan = CachePlan::new(None, &settings, "/style.css", || forced);
for (name, value) in [
("x-irrelevant", "yes"), ("cache-control", "no-store"),
("cache-control", "no-cache"), ("cache-control", "max-age=0"),
("cache-control", "min-fresh=broken"), ("authorization", "private"),
("cookie", "session=private"), ("range", "bytes=0-2"),
("range", "bytes=0-2,4-5"), ("if-range", "\"etag\""),
("if-match", "\"etag\""), ("upgrade", "websocket"),
] {
let mut headers = HeaderMap::new();
headers.insert(http::HeaderName::from_bytes(name.as_bytes()).unwrap(), value.parse().unwrap());
for method in [Method::GET, Method::HEAD, Method::POST] {
for body_empty in [false, true] {
let expected = if forced {
crate::cache::forced_request_policy(&method, &headers, body_empty)
} else {
crate::cache::request_policy(&method, &headers, body_empty, "/style.css", false, &settings)
};
assert_eq!(plan.request_policy(&method, &headers, body_empty, "/style.css", false, &settings), expected);
}
}
}
}
}
#[test]
fn request_header_rules_cannot_relax_bypass_or_revalidation() {
let cases = [
RequestPolicy::Use,
RequestPolicy::Revalidate("request_no_cache"),
RequestPolicy::Revalidate("request_max_age"),
RequestPolicy::Bypass("request_authorization"),
RequestPolicy::Bypass("request_no_store"),
];
for original in cases {
for rewritten in cases {
let expected = match (original, rewritten) {
(RequestPolicy::Bypass(reason), _) | (_, RequestPolicy::Bypass(reason)) => RequestPolicy::Bypass(reason),
(RequestPolicy::Revalidate(reason), _) | (_, RequestPolicy::Revalidate(reason)) => RequestPolicy::Revalidate(reason),
_ => RequestPolicy::Use,
};
assert_eq!(combine_policy(original, || rewritten), expected);
}
}
}
#[test]
fn cache_off_still_blocks_range_transformation_and_preserves_origin_headers() {
for method in [Method::GET, Method::HEAD, Method::POST] {
for range in [None, Some("bytes=0-2"), Some("bytes=0-2,4-5"), Some("broken"), Some("bytes=99-")] {
let mut headers = HeaderMap::new();
if let Some(range) = range { headers.insert("range", range.parse().unwrap()); }
headers.insert("if-range", HeaderValue::from_static("\"same-validator\""));
headers.insert("if-none-match", HeaderValue::from_static("\"client-validator\""));
let original = headers.clone();
let baseline = crate::cache::request_range(&method, &headers);
assert_eq!(range_selection(true, &method, &headers), baseline);
assert_eq!(matches!(range_selection(false, &method, &headers), RangeSelection::None), matches!(baseline, RangeSelection::None));
assert_eq!(headers, original, "origin must receive the original Range/conditional fields");
}
}
}
#[test]
fn force_cache_keeps_cookie_variants_and_disabled_cookie_matching_has_no_effect() {
let settings = settings();
let ordinary = CachePlan::new(None, &settings, "/style.css", || false);
assert!(ordinary.ignore_cookie());
let forced = CachePlan::new(None, &settings, "/style.css", || true);
assert!(forced.force_cache());
assert!(!forced.ignore_cookie());
let disabled = CachePlan::new(Some("route_disabled"), &settings, "/style.css", || true);
assert!(!disabled.force_cache());
assert!(!disabled.ignore_cookie());
}
+27 -53
View File
@@ -167,7 +167,7 @@ let body_empty = if cache_enabled
}
}
} else {
request.body().is_end_stream()
cache_enabled && request.body().is_end_stream()
};
let public_suffix = request
.uri()
@@ -175,29 +175,18 @@ let public_suffix = request
.map(|v| v.as_str())
.unwrap_or("/")
.to_owned();
let force_cache = policy_reason.is_none()
&& cache_settings.active_for(route.cache)
&& route
let cache_plan = cache_plan::CachePlan::new(policy_reason, cache_settings, &path, || {
cache_settings.active_for(route.cache) && route
.frontend
.as_ref()
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix));
let ignore_static_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
let original_policy = policy_reason
.map(crate::cache::RequestPolicy::Bypass)
.unwrap_or_else(|| {
if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
)
}
});
.is_some_and(|frontend| frontend.force_cache.matches(&path, &public_suffix))
});
let force_cache = cache_plan.force_cache();
let ignore_static_cookie = cache_plan.ignore_cookie();
let original_policy = cache_plan.request_policy(
request.method(), request.headers(), body_empty, &path,
request.uri().query().is_some(), cache_settings,
);
let only_cached =
cache_settings.active_for(route.cache) && crate::cache::only_if_cached(request.headers());
@@ -282,48 +271,31 @@ if websocket_legacy_support {
}
}
}
let range_selection = crate::cache::request_range(request.method(), request.headers());
let if_range = request.headers().get("if-range").cloned();
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
let post_policy = if force_cache {
crate::cache::forced_request_policy(request.method(), request.headers(), body_empty)
} else {
crate::cache::request_policy(
request.method(),
request.headers(),
body_empty,
&path,
request.uri().query().is_some(),
cache_settings,
let effective_policy = cache_plan::combine_policy(original_policy, || {
cache_plan.request_policy(
request.method(), request.headers(), body_empty, &path,
request.uri().query().is_some(), cache_settings,
)
};
});
let force_vary_cookie = force_cache && request.headers().contains_key("cookie");
let force_vary_authorization = force_cache && request.headers().contains_key("authorization");
let effective_policy = match (original_policy, post_policy) {
(crate::cache::RequestPolicy::Bypass(reason), _) => {
crate::cache::RequestPolicy::Bypass(reason)
}
(_, crate::cache::RequestPolicy::Bypass(reason)) => {
crate::cache::RequestPolicy::Bypass(reason)
}
(crate::cache::RequestPolicy::Revalidate(reason), _) => {
crate::cache::RequestPolicy::Revalidate(reason)
}
(_, crate::cache::RequestPolicy::Revalidate(reason)) => {
crate::cache::RequestPolicy::Revalidate(reason)
}
_ => crate::cache::RequestPolicy::Use,
};
let effective_reason = effective_policy.bypass_reason();
let revalidation_reason = effective_policy.revalidation_reason();
let effective_cacheable = effective_reason.is_none();
let range_selection = cache_plan::range_selection(
effective_cacheable, request.method(), request.headers(),
);
let if_range = if effective_cacheable {
request.headers().get("if-range").cloned()
} else { None };
let range_requested = !matches!(range_selection, crate::cache::RangeSelection::None);
// Match Varnish-style cache identity: a load-balanced backend member must not
// fragment the object cache. The vhost scope + canonical host + request target
// already identify the public resource; Vary handles representation variants.
let cache_route = route.id.as_str();
let mut cache_key_headers = None;
if ignore_static_cookie && request.headers().contains_key("cookie") {
if effective_cacheable && ignore_static_cookie && request.headers().contains_key("cookie") {
let mut headers = request.headers().clone();
headers.remove("cookie");
cache_key_headers = Some(headers);
@@ -344,7 +316,9 @@ let cache_ticket = if effective_cacheable {
} else {
None
};
let client_conditionals = request.headers().clone();
let client_conditionals = if cache_ticket.is_some() {
request.headers().clone()
} else { HeaderMap::new() };
let cached_revalidation = if let Some(ticket) = cache_ticket.as_ref() {
state.cache.revalidation_tiered(ticket).await
} else {
+2 -1
View File
@@ -94,7 +94,8 @@ if response.status() == StatusCode::NOT_MODIFIED && revalidation_sent {
// Both origin and transformed policies must be cacheable; hiding a
// Set-Cookie/private header never authorizes storage of private content.
let transformed_plan =
if ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie") {
if origin_plan.is_none()
|| (ignore_static_cookie && crate::cache::varies_on(response.headers(), "cookie")) {
None
} else {
crate::cache::plan(
+6 -1
View File
@@ -1,5 +1,10 @@
{
let origin_validation_headers = response.headers().clone();
// Only a real cache revalidation needs origin headers after response rules.
let origin_validation_headers = if revalidation_sent
&& response.status() == StatusCode::NOT_MODIFIED
{
response.headers().clone()
} else { HeaderMap::new() };
if let Some(exchange) = &exchange {
exchange
.upstream_status
+3 -1
View File
@@ -119,7 +119,9 @@ if websocket_upstream_h2 {
// Do not classify a slow client upload as a slow origin response. Probes
// independently measure origin liveness/latency with body-free requests.
let request_body_empty = request.body().size_hint().exact() == Some(0);
let proactive_base_headers = request.headers().clone();
let proactive_base_headers = if cache_ticket.is_some() && cache_settings.proactive_refresh_enabled {
request.headers().clone()
} else { HeaderMap::new() };
let response_header_timeout = frontend
.and_then(|frontend| frontend.proxy_timeouts.response_header_timeout())
.unwrap_or(state.header_timeout);
+48 -38
View File
@@ -28,7 +28,9 @@
// makes PHP-FPM omit HTTP_HOST, breaking WordPress.
request.headers_mut().insert(HOST, original_host.clone());
let request_method = request.method().clone();
let request_headers = request.headers().clone();
// This branch owns local-origin delivery. Move its headers instead of
// cloning them; the request remains only for exchange/body extensions.
let mut request_headers = std::mem::take(request.headers_mut());
let request_uri = request.uri().clone();
let request_version = request.version();
let public_suffix = request_uri.path_and_query()
@@ -41,32 +43,33 @@
Err(_) => return error(StatusCode::BAD_REQUEST,
"RustProxy: invalid or incomplete request body\n"),
}
} else { request.body().is_end_stream() };
let force_cache = enabled && route.frontend.as_ref().is_some_and(|frontend|
frontend.force_cache.matches(&path, &public_suffix));
let policy = if enabled {
if force_cache {
crate::cache::forced_request_policy(&request_method, &request_headers, empty_body)
} else {
crate::cache::request_policy(
&request_method, &request_headers, empty_body, &path,
request_uri.query().is_some(), cache_settings)
}
} else {
crate::cache::RequestPolicy::Bypass("cache_disabled")
};
} else { enabled && request.body().is_end_stream() };
let cache_plan = cache_plan::CachePlan::new(
(!enabled).then_some("cache_disabled"), cache_settings, &path,
|| route.frontend.as_ref().is_some_and(|frontend|
frontend.force_cache.matches(&path, &public_suffix)),
);
let force_cache = cache_plan.force_cache();
let ignore_cookie = cache_plan.ignore_cookie();
let policy = cache_plan.request_policy(
&request_method, &request_headers, empty_body, &path,
request_uri.query().is_some(), cache_settings,
);
// HEAD can use an existing GET representation but may not fill a cache
// entry with its empty downstream body.
let eligible = enabled && policy.bypass_reason().is_none();
let mut key_headers = request_headers.clone();
let ignore_cookie = !force_cache && cache_settings.ignore_cookie_for_path(&path);
if ignore_cookie { key_headers.remove("cookie"); }
let key_headers = if eligible && ignore_cookie && request_headers.contains_key("cookie") {
let mut headers = request_headers.clone();
headers.remove("cookie");
Some(headers)
} else { None };
let cache_ticket = if eligible {
state.cache.ticket_scoped(state.cache.revision(), scope, context.scheme,
original_host.to_str().unwrap_or(""), &route.id, &public_suffix, &key_headers)
original_host.to_str().unwrap_or(""), &route.id, &public_suffix,
key_headers.as_ref().unwrap_or(&request_headers))
} else { None };
let range = crate::cache::request_range(&request_method, &request_headers);
let if_range = request_headers.get("if-range").cloned();
let range = cache_plan::range_selection(eligible, &request_method, &request_headers);
let if_range = if eligible { request_headers.get("if-range").cloned() } else { None };
let range_requested = !matches!(range, crate::cache::RangeSelection::None);
// Unlike the original Web Server path, retain stale and client-forced
// revalidation candidates. A local 304 can refresh them without replacing
@@ -122,9 +125,14 @@
// Neither the Request nor the streaming Body is held across a borrowed
// await: the filesystem/PHP source owns the body for its full lifetime.
let static_body = std::mem::replace(request.body_mut(), Body::empty());
let (origin_request_headers, revalidation_sent) =
let (origin_request_headers, revalidation_sent) = if cache_ticket.is_some() {
web_cache_revalidation::web_origin_request_headers(
&request_headers, cached_revalidation.as_ref());
&request_headers, cached_revalidation.as_ref())
} else {
// No cache result can need the original conditionals later. Preserve
// all origin HTTP semantics by handing over the untouched header map.
(std::mem::take(&mut request_headers), false)
};
let started = Instant::now();
let response = crate::web_server::serve(service, request_method.clone(),
origin_request_headers, request_uri, &service_path, static_body,
@@ -181,25 +189,27 @@
state.cache.discard(ticket);
}
}
let origin_headers = response.headers().clone();
let original_plan = crate::cache::plan(response.status(), &origin_headers,
cache_settings, started.elapsed());
// Do not allow a later response-header rewrite to override a private,
// explicitly uncacheable PHP/CGI origin response.
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|| origin_headers.contains_key("expires")
|| origin_headers.contains_key("set-cookie")
|| origin_headers.contains_key("vary");
let origin_safe = !explicit_origin_policy || original_plan.is_some();
let no_transform = crate::transform::no_transform(&origin_headers);
let skip_compression = crate::compression::no_transform(&origin_headers);
let origin_headers = response.headers();
let origin_safe = cache_ticket.is_some() && {
let explicit_origin_policy = origin_headers.contains_key("cache-control")
|| origin_headers.contains_key("expires")
|| origin_headers.contains_key("set-cookie")
|| origin_headers.contains_key("vary");
!explicit_origin_policy || crate::cache::plan(response.status(), origin_headers,
cache_settings, started.elapsed()).is_some()
};
let no_transform = crate::transform::no_transform(origin_headers);
let skip_compression = crate::compression::no_transform(origin_headers);
let origin_validators = crate::transform::OriginValidators {
etag: origin_headers.get("etag").cloned(),
last_modified: origin_headers.get("last-modified").cloned(),
};
if skip_compression || range_requested {
response.extensions_mut().insert(crate::compression::Skip);
}
response.extensions_mut().insert(crate::transform::OriginValidators {
etag: origin_headers.get("etag").cloned(),
last_modified: origin_headers.get("last-modified").cloned(),
});
response.extensions_mut().insert(origin_validators);
apply_response_rules(response.headers_mut(), &snapshot, route.frontend.as_deref(),
Some(route), false);
if response.extensions().get::<crate::pages::PageServed>().is_some() {
@@ -271,4 +281,4 @@
let transformed = response.extensions().get::<crate::transform::Applied>().is_some();
cache_settings.annotate_transformed(response.headers_mut(), transformed);
response
}
}
+3
View File
@@ -1,6 +1,7 @@
include!("types.rs");
include!("error_fallback.rs");
include!("cache_helpers.rs");
mod cache_plan;
mod web_cache_revalidation;
include!("cache_http_invalidation.rs");
include!("upstream_helpers.rs");
@@ -14,3 +15,5 @@ include!("tests/h3_navigation_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
#[cfg(test)]
include!("tests/cache_fast_path_tests.rs");
+26
View File
@@ -0,0 +1,26 @@
fn request_id_hash(sequence: u64) -> String {
use rand::TryRng;
use sha2::{Digest, Sha256};
let mut nonce = [0u8; 32];
let mut rng = rand::rngs::SysRng;
rng.try_fill_bytes(&mut nonce)
.expect("secure operating system random source unavailable");
let mut hash = Sha256::new();
hash.update(sequence.to_be_bytes());
hash.update(nonce);
request_id_hex(&hash.finalize())
}
fn request_id_hex(digest: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut encoded = String::with_capacity(digest.len() * 2);
for &byte in digest {
encoded.push(HEX[(byte >> 4) as usize] as char);
encoded.push(HEX[(byte & 0x0f) as usize] as char);
}
encoded
}
#[cfg(test)]
include!("request_id/tests.rs");
+58
View File
@@ -0,0 +1,58 @@
mod request_id_tests {
use super::*;
use sha2::{Digest, Sha256};
#[test]
fn hexadecimal_encoding_matches_previous_sha256_display() {
for byte in 0u8..=255 {
let digest = Sha256::digest([byte; 32]);
assert_eq!(request_id_hex(&digest), format!("{digest:x}"));
}
assert_eq!(
request_id_hex(&[0, 1, 15, 16, 127, 128, 254, 255]),
"00010f107f80feff"
);
}
#[test]
fn request_ids_keep_their_64_hexadecimal_format_and_randomness() {
let mut seen = std::collections::HashSet::new();
for _ in 0..512 {
let id = request_id_hash(1);
assert_eq!(id.len(), 64);
assert!(id
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)));
assert!(
seen.insert(id),
"fixed sequence must still use independent OS entropy"
);
}
}
#[test]
#[ignore = "explicit release-mode microbenchmark; not an acceptance threshold"]
fn benchmark_request_id_formatting() {
use std::hint::black_box;
const ITERATIONS: u32 = 200_000;
let digest = Sha256::digest(b"same SHA256 digest for both formatters");
for round in 0..7 {
for candidate in if round % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
for _ in 0..ITERATIONS {
let value = if candidate {
request_id_hex(black_box(&digest))
} else {
format!("{:x}", black_box(&digest))
};
black_box(value);
}
println!("{{\"round\":{round},\"candidate\":{candidate},\"iterations\":{ITERATIONS},\"ns_per_op\":{}}}", start.elapsed().as_nanos() as f64 / ITERATIONS as f64);
}
}
}
}
+5 -5
View File
@@ -218,6 +218,7 @@ mod authority_tests {
#[test]
fn generated_policy_and_gateway_errors_use_html_pages() {
for status in [
StatusCode::BAD_GATEWAY,
StatusCode::FORBIDDEN,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::SERVICE_UNAVAILABLE,
@@ -231,11 +232,6 @@ mod authority_tests {
);
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
}
let response = error(StatusCode::BAD_GATEWAY, "internal detail\n");
assert_ne!(
response.headers().get("content-type").unwrap(),
"text/html; charset=utf-8"
);
}
#[test]
fn locally_generated_pages_remove_cache_metadata() {
@@ -344,6 +340,7 @@ mod authority_tests {
#[test]
fn backend_diagnostics_are_opt_in_and_strip_spoofed_values() {
let mut response = Response::new(Body::empty());
response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof"));
response
.headers_mut()
.insert("x-rust-proxy-backend", HeaderValue::from_static("spoofed"));
@@ -356,6 +353,7 @@ mod authority_tests {
HeaderValue::from_static("999ms"),
);
apply_backend_diagnostic_values(&mut response, false, false);
assert!(response.headers().get("x-rustproxy-backend").is_none());
assert!(response.headers().get("x-rust-proxy-backend").is_none());
assert!(response
.headers()
@@ -369,7 +367,9 @@ mod authority_tests {
response
.extensions_mut()
.insert(OriginTiming(Duration::from_millis(12)));
response.headers_mut().insert("x-rustproxy-backend", HeaderValue::from_static("canonical-spoof"));
apply_backend_diagnostic_values(&mut response, true, true);
assert!(response.headers().get("x-rustproxy-backend").is_none());
assert_eq!(
response.headers().get("x-rust-proxy-backend").unwrap(),
"origin-a"
+319
View File
@@ -0,0 +1,319 @@
mod cache_fast_path_tests {
use super::*;
use crate::{auth::Auth, config::{self, Config, Update, ValidationPolicy}, db::Store};
use std::net::SocketAddr;
use tempfile::TempDir;
use tower::ServiceExt;
const CSS: &[u8] = b"/* origin comment */\nbody { color: red; margin: 0px; }\n";
struct Fixture { app: Arc<App>, _directory: TempDir }
struct Received { status: StatusCode, headers: HeaderMap, body: Bytes }
impl Fixture {
async fn new(config: Config) -> Self {
let directory = TempDir::new().unwrap();
let policy = ValidationPolicy {
tls_addr: None, cert_dir: None, data_dir: None,
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
};
let store = Store::open(&directory.path().join("db"), &config).await.unwrap();
let auth = Auth::new(store.clone(), directory.path().to_owned()).await.unwrap();
let snapshot = config::compile(config, 0, &policy).unwrap();
let app = App::new(store, snapshot, policy, auth, 32,
Duration::from_secs(5), Duration::from_secs(5), Duration::from_secs(30));
Self { app, _directory: directory }
}
async fn send(&self, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received {
send(self.app.clone(), method, path, headers, body).await
}
async fn get(&self, path: &str, headers: &[(&str, &str)]) -> Received {
self.send(Method::GET, path, headers, Body::empty()).await
}
async fn change(&self, edit: impl FnOnce(&mut Config)) {
let current = self.app.config.load_full();
let mut config = current.envelope.config.clone();
edit(&mut config);
self.app.update(Update { expected_revision: current.envelope.revision, config }).await.unwrap();
}
async fn stop(self) {
self.app.shutdown.cancel();
self.app.tunnels.close();
self.app.tunnels.wait().await;
self.app.store.close().await;
}
}
async fn send(app: Arc<App>, method: Method, path: &str, headers: &[(&str, &str)], body: Body) -> Received {
let mut builder = Request::builder().method(method).uri(path)
.header(HOST, "localhost").header(USER_AGENT, "cache-plan-regression/1.0");
for (name, value) in headers { builder = builder.header(*name, *value); }
let mut request = builder.body(body).unwrap();
request.extensions_mut().insert(PeerAddr("192.0.2.50:45678".parse().unwrap()));
request.extensions_mut().insert(LocalAddr("127.0.0.1:8080".parse().unwrap()));
let response = router(app).oneshot(request).await.unwrap();
let (parts, body) = response.into_parts();
let body = body.collect().await.unwrap().to_bytes();
Received { status: parts.status, headers: parts.headers, body }
}
fn config_for(origin: SocketAddr, enabled: bool) -> Config {
let mut config = config::demo_config(origin);
config.cache.enabled = enabled;
config.cache.expose_status = true;
config.cache.expose_hits = true;
config.cache.expose_cache_status = true;
config.cache.expose_only_hits = false;
config.cache.static_ignore_cookies = false;
config
}
fn web_config(directory: &TempDir, enabled: bool) -> Config {
std::fs::write(directory.path().join("style.css"), CSS).unwrap();
let mut config = config_for("127.0.0.1:9000".parse().unwrap(), enabled);
config.web_services.push(crate::web_server::Service {
id: "site".into(), name: "Site".into(), document_root: directory.path().to_owned(),
cache_control: Some("public, max-age=600".into()), ..Default::default()
});
config.vhosts[0].source = crate::vhost::Source::WebServer { web_service_id: "site".into() };
config.vhosts[0].backends.clear();
config.vhosts[0].routes.clear();
config
}
#[tokio::test]
async fn web_cache_disabled_keeps_get_head_range_conditionals_and_observability() {
let directory = TempDir::new().unwrap();
let fixture = Fixture::new(web_config(&directory, false)).await;
let first = fixture.get("/style.css", &[]).await;
assert_eq!(first.status, StatusCode::OK);
assert_eq!(first.body, CSS);
assert!(!first.headers.contains_key("x-cache"));
let etag = first.headers["etag"].to_str().unwrap();
let head = fixture.send(Method::HEAD, "/style.css", &[], Body::empty()).await;
assert_eq!(head.status, StatusCode::OK);
assert_eq!(head.headers["content-length"], CSS.len().to_string());
assert!(head.body.is_empty());
let partial = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", etag)]).await;
assert_eq!(partial.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(partial.body, &CSS[..4]);
let mismatch = fixture.get("/style.css", &[("range", "bytes=0-3"), ("if-range", "\"different\"")]).await;
assert_eq!(mismatch.status, StatusCode::OK);
assert_eq!(mismatch.body, CSS);
let conditional = fixture.get("/style.css", &[("if-none-match", etag)]).await;
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
assert!(conditional.body.is_empty());
let invalid = fixture.get("/style.css", &[("range", "bytes=99999-")]).await;
assert_eq!(invalid.status, StatusCode::RANGE_NOT_SATISFIABLE);
assert_eq!(invalid.headers["content-range"], format!("bytes */{}", CSS.len()));
let only_cached = fixture.get("/style.css", &[("cache-control", "only-if-cached")]).await;
assert_eq!(only_cached.body, CSS);
let logs = fixture.app.logs.recent();
assert_eq!(logs.len(), 7);
assert!(logs.iter().all(|entry| entry.cache_reason == "cache_disabled"));
assert!(logs.iter().all(|entry| entry.request_id.len() == 64));
assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 7);
assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0);
assert_eq!(fixture.app.logs.stats().queue_dropped, 0);
fixture.stop().await;
}
#[tokio::test]
async fn web_cache_hot_toggles_keep_miss_hit_and_bypass_diagnostics() {
let directory = TempDir::new().unwrap();
let fixture = Fixture::new(web_config(&directory, false)).await;
assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache"));
fixture.change(|config| config.cache.enabled = true).await;
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS");
let hit = fixture.get("/style.css", &[]).await;
assert_eq!(hit.headers["x-cache"], "HIT");
assert_eq!(hit.body, CSS);
fixture.change(|config| config.vhosts[0].routes.push(crate::vhost::PathRoute {
id: "uncached".into(), path_prefix: "/style.css".into(), cache: Some(false),
source: crate::vhost::Source::WebServer { web_service_id: "site".into() },
..Default::default()
})).await;
let bypass = fixture.get("/style.css", &[]).await;
assert_eq!(bypass.headers["x-cache"], "BYPASS");
assert_eq!(bypass.headers["x-cache-reason"], "cache_disabled");
fixture.change(|config| {
config.vhosts[0].routes.clear();
config.vhosts[0].cache.enabled = Some(false);
}).await;
assert!(!fixture.get("/style.css", &[]).await.headers.contains_key("x-cache"));
fixture.change(|config| config.vhosts[0].cache.enabled = None).await;
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "MISS");
assert_eq!(fixture.get("/style.css", &[]).await.headers["x-cache"], "HIT");
fixture.stop().await;
}
#[tokio::test]
async fn web_transform_remains_independent_of_disabled_cache_and_range() {
let directory = TempDir::new().unwrap();
let mut config = web_config(&directory, false);
config.transform.enabled = true;
config.vhosts[0].transform.enabled = true;
let fixture = Fixture::new(config).await;
let normal = fixture.get("/style.css", &[]).await;
assert_eq!(normal.status, StatusCode::OK);
assert!(normal.body.len() < CSS.len(), "transform must remain active with cache off");
let range = fixture.get("/style.css", &[("range", "bytes=0-3"), ("accept-encoding", "gzip")]).await;
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(range.body, &CSS[..4]);
assert!(!range.headers.contains_key("content-encoding"));
fixture.change(|config| config.vhosts[0].transform.enabled = false).await;
assert_eq!(fixture.get("/style.css", &[]).await.body, CSS);
fixture.stop().await;
}
#[tokio::test]
async fn cache_fast_paths_keep_global_and_location_access_control() {
let directory = TempDir::new().unwrap();
for enabled in [false, true] {
let mut config = web_config(&directory, enabled);
let deny = crate::acl::Acl { default: crate::acl::Action::Deny, rules: vec![] };
config.host_policies.push(config::HostPolicy { hosts: vec!["localhost".into()], acl: deny.clone() });
let fixture = Fixture::new(config).await;
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN);
fixture.change(|config| {
config.host_policies.clear();
config.vhosts[0].routes.push(crate::vhost::PathRoute {
id: "private".into(), path_prefix: "/style.css".into(), acl: Some(deny),
source: crate::vhost::Source::WebServer { web_service_id: "site".into() },
..Default::default()
});
}).await;
assert_eq!(fixture.get("/style.css", &[]).await.status, StatusCode::FORBIDDEN);
fixture.stop().await;
}
}
struct Origin { addr: SocketAddr, requests: Arc<AtomicU64>, task: tokio::task::JoinHandle<()> }
impl Drop for Origin { fn drop(&mut self) { self.task.abort(); } }
async fn origin() -> Origin {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(AtomicU64::new(0));
let count = requests.clone();
let app = Router::new().fallback(move |request: Request| {
let count = count.clone();
async move {
count.fetch_add(1, Ordering::Relaxed);
let (parts, body) = request.into_parts();
if parts.uri.path() == "/slow" { tokio::time::sleep(Duration::from_millis(25)).await; }
let input = body.collect().await.unwrap().to_bytes();
let variant = parts.headers.get("x-variant").and_then(|v| v.to_str().ok()).unwrap_or("default");
let output = if parts.method == Method::POST { input } else { Bytes::from(format!("origin-{variant}")) };
let mut response = Response::new(Body::from(output.clone()));
response.headers_mut().insert("etag", HeaderValue::from_static("\"origin-v1\""));
response.headers_mut().insert("cache-control", HeaderValue::from_static(
if parts.uri.path() == "/private" { "private, max-age=600" } else { "public, max-age=600" }));
response.headers_mut().insert("vary", HeaderValue::from_static("X-Variant"));
response.headers_mut().insert("content-type", HeaderValue::from_static("text/plain"));
for name in ["range", "if-range", "if-none-match", "cookie", "authorization", "x-request-id"] {
if let Some(value) = parts.headers.get(name) {
let echoed: http::HeaderName = format!("x-seen-{name}").parse().unwrap();
response.headers_mut().insert(echoed, value.clone());
}
}
if parts.headers.get("if-none-match").is_some_and(|v| v == "\"origin-v1\"") {
*response.status_mut() = StatusCode::NOT_MODIFIED;
*response.body_mut() = Body::empty();
} else if parts.headers.get("range").is_some_and(|v| v == "bytes=0-2")
&& parts.headers.get("if-range").is_none_or(|v| v == "\"origin-v1\"") {
*response.status_mut() = StatusCode::PARTIAL_CONTENT;
response.headers_mut().insert("content-range", format!("bytes 0-2/{}", output.len()).parse().unwrap());
*response.body_mut() = Body::from(output.slice(..3));
}
response
}
});
let task = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); });
Origin { addr, requests, task }
}
#[tokio::test]
async fn proxy_disabled_cache_preserves_headers_post_and_conditionals() {
let origin = origin().await;
let fixture = Fixture::new(config_for(origin.addr, false)).await;
let range = fixture.get("/inspect", &[("range", "bytes=0-2"), ("if-range", "\"origin-v1\""),
("cookie", "session=one"), ("authorization", "Bearer example")]).await;
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(range.body, "ori");
assert_eq!(range.headers["x-seen-range"], "bytes=0-2");
assert_eq!(range.headers["x-seen-if-range"], "\"origin-v1\"");
assert_eq!(range.headers["x-seen-cookie"], "session=one");
assert_eq!(range.headers["x-seen-authorization"], "Bearer example");
assert_eq!(range.headers["x-seen-x-request-id"].as_bytes().len(), 64);
assert!(!range.headers.contains_key("x-cache"));
let conditional = fixture.get("/inspect", &[("if-none-match", "\"origin-v1\"")]).await;
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
assert!(conditional.body.is_empty());
let input = futures_util::stream::iter([
Ok::<_, std::io::Error>(Bytes::from_static(b"streamed ")),
Ok(Bytes::from_static(b"content")),
]);
let post = fixture.send(Method::POST, "/echo", &[], Body::from_stream(input)).await;
assert_eq!(post.status, StatusCode::OK);
assert_eq!(post.body, "streamed content");
assert_eq!(origin.requests.load(Ordering::Relaxed), 3);
assert!(fixture.app.logs.recent().iter().all(|entry| entry.cache_reason == "cache_disabled"));
fixture.stop().await;
}
#[tokio::test]
async fn proxy_cache_on_preserves_vary_private_revalidation_and_ranges() {
let origin = origin().await;
let fixture = Fixture::new(config_for(origin.addr, true)).await;
for variant in ["a", "b"] {
assert_eq!(fixture.get("/vary", &[("x-variant", variant)]).await.headers["x-cache"], "MISS");
}
let hit = fixture.get("/vary", &[("x-variant", "a")]).await;
assert_eq!(hit.headers["x-cache"], "HIT");
assert_eq!(hit.body, "origin-a");
let range = fixture.get("/vary", &[("x-variant", "a"), ("range", "bytes=0-2"), ("if-range", "\"origin-v1\"")]).await;
assert_eq!(range.status, StatusCode::PARTIAL_CONTENT);
assert_eq!(range.headers["x-cache"], "HIT");
assert_eq!(range.body, "ori");
let validated = fixture.get("/vary", &[("x-variant", "a"), ("cache-control", "no-cache")]).await;
assert_eq!(validated.status, StatusCode::OK);
assert_eq!(validated.headers["x-cache"], "HIT");
assert_eq!(validated.headers["x-cache-reason"], "cache_revalidated");
assert_eq!(validated.body, "origin-a");
let conditional = fixture.get("/vary", &[("x-variant", "a"), ("if-none-match", "\"origin-v1\"")]).await;
assert_eq!(conditional.status, StatusCode::NOT_MODIFIED);
let bypass = fixture.get("/vary", &[("authorization", "Bearer example")]).await;
assert_eq!(bypass.headers["x-cache-reason"], "request_authorization");
for _ in 0..2 {
assert_eq!(fixture.get("/private", &[]).await.headers["x-cache"], "MISS");
}
assert_eq!(origin.requests.load(Ordering::Relaxed), 6);
fixture.stop().await;
}
#[tokio::test]
async fn proxy_cache_enabled_concurrent_fill_stays_single_flight() {
let origin = origin().await;
let fixture = Fixture::new(config_for(origin.addr, true)).await;
let mut requests = tokio::task::JoinSet::new();
for _ in 0..12 {
let app = fixture.app.clone();
requests.spawn(async move { send(app, Method::GET, "/slow", &[], Body::empty()).await });
}
while let Some(result) = requests.join_next().await {
let response = result.unwrap();
assert_eq!(response.status, StatusCode::OK);
assert_eq!(response.body, "origin-default");
}
assert_eq!(origin.requests.load(Ordering::Relaxed), 1);
assert_eq!(fixture.app.metrics.completed.load(Ordering::Relaxed), 12);
assert_eq!(fixture.app.metrics.active.load(Ordering::Relaxed), 0);
fixture.stop().await;
}
}
+9 -14
View File
@@ -1,16 +1,4 @@
fn request_id_hash(sequence: u64) -> String {
use rand::TryRng;
use sha2::{Digest, Sha256};
let mut nonce = [0u8; 32];
let mut rng = rand::rngs::SysRng;
rng.try_fill_bytes(&mut nonce)
.expect("secure operating system random source unavailable");
let mut hash = Sha256::new();
hash.update(sequence.to_be_bytes());
hash.update(nonce);
format!("{:x}", hash.finalize())
}
include!("request_id.rs");
fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool {
!minimal_welcome
@@ -43,7 +31,13 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.unwrap_or_default()
.tls;
request.extensions_mut().insert(snapshot.clone());
let original_headers = request.headers().clone();
let original_headers = if snapshot.envelope.config.compression.enabled()
&& request.method() != Method::HEAD
{
crate::compression::request_headers(request.headers())
} else {
HeaderMap::new()
};
let method = request.method().clone();
let is_head = method == Method::HEAD;
let ws_attempt = request
@@ -242,6 +236,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
"x-rustproxy-request-id",
"x-rustproxy-revision",
"x-rust-proxy-backend",
"x-rustproxy-backend",
"x-rust-proxy-backend-time",
"x-rustproxy-backend-time",
"x-cache",