Skip to content

Commit 76e00b2

Browse files
committed
always add Vary: Accept-Encoding for compressible content-types
1 parent 52c5842 commit 76e00b2

File tree

3 files changed

+118
-43
lines changed

3 files changed

+118
-43
lines changed

dropshot/src/compression.rs

Lines changed: 58 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,41 @@ pub fn accepts_gzip_encoding(headers: &HeaderMap<HeaderValue>) -> bool {
9797
false
9898
}
9999

100+
/// Checks if a content type is compressible.
101+
/// This is used to determine if the Vary: Accept-Encoding header should be added,
102+
/// even if compression doesn't occur for this particular request.
103+
pub fn is_compressible_content_type(
104+
response_headers: &HeaderMap<HeaderValue>,
105+
) -> bool {
106+
// Only compress when we know the content type
107+
let Some(content_type) = response_headers.get(http::header::CONTENT_TYPE)
108+
else {
109+
return false;
110+
};
111+
let Ok(ct_str) = content_type.to_str() else {
112+
return false;
113+
};
114+
115+
let ct_lower = ct_str.to_ascii_lowercase();
116+
117+
// SSE streams prioritize latency over compression
118+
if ct_lower.starts_with("text/event-stream") {
119+
return false;
120+
}
121+
122+
let is_compressible = ct_lower.starts_with("application/json")
123+
|| ct_lower.starts_with("text/")
124+
|| ct_lower.starts_with("application/xml")
125+
|| ct_lower.starts_with("application/javascript")
126+
|| ct_lower.starts_with("application/x-javascript");
127+
128+
// RFC 6839 structured syntax suffixes (+json, +xml)
129+
let has_compressible_suffix =
130+
ct_lower.contains("+json") || ct_lower.contains("+xml");
131+
132+
is_compressible || has_compressible_suffix
133+
}
134+
100135
/// Determines if a response should be compressed with gzip.
101136
pub fn should_compress_response(
102137
request_method: &http::Method,
@@ -151,33 +186,9 @@ pub fn should_compress_response(
151186
}
152187
}
153188

154-
// Only compress when we know the content type
155-
let Some(content_type) = response_headers.get(http::header::CONTENT_TYPE)
156-
else {
157-
return false;
158-
};
159-
let Ok(ct_str) = content_type.to_str() else {
160-
return false;
161-
};
162-
163-
let ct_lower = ct_str.to_ascii_lowercase();
164-
165-
// SSE streams prioritize latency over compression
166-
if ct_lower.starts_with("text/event-stream") {
167-
return false;
168-
}
169-
170-
let is_compressible = ct_lower.starts_with("application/json")
171-
|| ct_lower.starts_with("text/")
172-
|| ct_lower.starts_with("application/xml")
173-
|| ct_lower.starts_with("application/javascript")
174-
|| ct_lower.starts_with("application/x-javascript");
175-
176-
// RFC 6839 structured syntax suffixes (+json, +xml)
177-
let has_compressible_suffix =
178-
ct_lower.contains("+json") || ct_lower.contains("+xml");
179-
180-
is_compressible || has_compressible_suffix
189+
// technically redundant with check outside of the call, but kept here
190+
// because it's logically part of "should compress?" question
191+
is_compressible_content_type(response_headers)
181192
}
182193

183194
/// Minimum size in bytes for a response to be compressed.
@@ -222,18 +233,7 @@ pub fn apply_gzip_compression(response: Response<Body>) -> Response<Body> {
222233

223234
// Vary header is critical for caching - prevents serving compressed
224235
// responses to clients that don't accept gzip
225-
let vary_has_accept_encoding = parts
226-
.headers
227-
.get_all(http::header::VARY)
228-
.iter()
229-
.any(header_value_contains_accept_encoding);
230-
231-
if !vary_has_accept_encoding {
232-
parts.headers.append(
233-
http::header::VARY,
234-
HeaderValue::from_static("Accept-Encoding"),
235-
);
236-
}
236+
add_vary_header(&mut parts.headers);
237237

238238
// because we're streaming, we can't handle ranges and we don't know the
239239
// length of the response ahead of time
@@ -250,6 +250,25 @@ fn header_value_contains_accept_encoding(value: &HeaderValue) -> bool {
250250
})
251251
}
252252

253+
/// Adds the Vary: Accept-Encoding header to a response if not already present.
254+
/// This is critical for correct caching behavior with intermediate caches.
255+
pub fn add_vary_header(headers: &mut HeaderMap<HeaderValue>) {
256+
let vary_values = headers.get_all(http::header::VARY);
257+
258+
// can't and shouldn't add anything if we already have "*"
259+
// https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.4
260+
if vary_values.iter().any(|v| v == "*") {
261+
return;
262+
}
263+
264+
if !vary_values.iter().any(header_value_contains_accept_encoding) {
265+
headers.append(
266+
http::header::VARY,
267+
HeaderValue::from_static("Accept-Encoding"),
268+
);
269+
}
270+
}
271+
253272
#[cfg(test)]
254273
mod tests {
255274
use super::*;

dropshot/src/server.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
44
use super::api_description::ApiDescription;
55
use super::body::Body;
6+
use super::compression::add_vary_header;
67
use super::compression::apply_gzip_compression;
8+
use super::compression::is_compressible_content_type;
79
use super::compression::should_compress_response;
810
use super::config::{ConfigDropshot, ConfigTls};
911
#[cfg(feature = "usdt-probes")]
@@ -990,15 +992,29 @@ async fn http_request_handle<C: ServerContext>(
990992
};
991993

992994
if server.config.compression
993-
&& should_compress_response(
995+
&& is_compressible_content_type(response.headers())
996+
{
997+
// Add Vary: Accept-Encoding header for all compressible content
998+
// types. This needs to be there even if the response ends up not being
999+
// compressed because it tells caches how to handle the possibility that
1000+
// responses could be compressed or not.
1001+
//
1002+
// This tells caches (like browsers and CDNs) that the response content
1003+
// depends on the value of the Accept-Encoding header. Without this, a
1004+
// cache might mistakenly serve a compressed response to a client that
1005+
// cannot decompress it, or serve an uncompressed response to a client
1006+
// that could have benefited from compression.
1007+
add_vary_header(response.headers_mut());
1008+
1009+
if should_compress_response(
9941010
&method,
9951011
&request_headers,
9961012
response.status(),
9971013
response.headers(),
9981014
response.extensions(),
999-
)
1000-
{
1001-
response = apply_gzip_compression(response);
1015+
) {
1016+
response = apply_gzip_compression(response);
1017+
}
10021018
}
10031019

10041020
response.headers_mut().insert(

dropshot/tests/integration-tests/gzip.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,3 +690,43 @@ async fn test_compression_config_enabled() {
690690

691691
testctx.teardown().await;
692692
}
693+
694+
#[tokio::test]
695+
async fn test_vary_header_on_non_gzip_requests() {
696+
// Test that Vary: Accept-Encoding is present even when client doesn't
697+
// accept gzip. This is critical for correct caching behavior.
698+
//
699+
// Without this header, the following sequence causes incorrect behavior:
700+
// 1. Client A (no gzip) requests resource → cache stores uncompressed
701+
// response without Vary header
702+
// 2. Client B (gzip support) requests same resource → cache serves the
703+
// uncompressed version, even though Client B could handle compression
704+
let testctx = test_setup("vary_header_no_gzip", api());
705+
let client = &testctx.client_testctx;
706+
707+
// Request WITHOUT Accept-Encoding: gzip for a compressible resource
708+
let uri = client.url("/large-response");
709+
let response = make_plain_request_response(client, &uri).await;
710+
711+
// Should NOT be compressed
712+
assert!(
713+
response.headers().get(header::CONTENT_ENCODING).is_none(),
714+
"Response should not be compressed"
715+
);
716+
717+
// Should still have Vary: Accept-Encoding header
718+
assert!(
719+
response.headers().contains_key(header::VARY),
720+
"Response should have Vary header even without gzip encoding"
721+
);
722+
723+
let vary_value =
724+
response.headers().get(header::VARY).unwrap().to_str().unwrap();
725+
assert!(
726+
vary_value.to_lowercase().contains("accept-encoding"),
727+
"Vary header should include Accept-Encoding, got: {}",
728+
vary_value
729+
);
730+
731+
testctx.teardown().await;
732+
}

0 commit comments

Comments
 (0)