edge: gzip eligibility predicate
Header-only, decided once. A missing Content-Type is refused rather than sniffed: sniffing runs on bytes we would have already compressed and would label the response application/x-gzip.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc
2 files changed,
+173
−0
internal/edge/compress.go+86 −0internal/edge/compress_test.go+87 −0
diff --git a/internal/edge/compress.go b/internal/edge/compress.go| index 78e2ca286..4d3de221a 100644 |
| --- a/internal/edge/compress.go |
| +++ b/internal/edge/compress.go |
| @@ -61,3 +61,89 @@ func qValue(params string) float64 { |
| } |
| return 1 |
| } |
| + |
| +// minCompressSize is the Content-Length below which compressing is not worth |
| +// the CPU: gzip's own header and trailer are 18 bytes, and a sub-kilobyte |
| +// response often lands in one packet either way. A response whose length is |
| +// UNKNOWN (streamed, chunked) is compressed regardless — refusing those would |
| +// exempt most proxied HTML, which is the bulk of what there is to save. |
| +const minCompressSize = 1024 |
| + |
| +// compressible reports whether a response with these headers and this status |
| +// should be gzipped. The request-side questions — does the client accept |
| +// gzip, is this a HEAD — are settled once at wrapper construction |
| +// (newGzipWriter), so this reads the response alone. |
| +// |
| +// Every false below is a response that would be WRONG or pointless to |
| +// compress, not merely unprofitable: |
| +// |
| +// - 204/205 have no body; 304 has none and must keep the validators of the |
| +// representation it refers to; 206 is a byte range of a larger body, and |
| +// compressing a range makes the offsets it names meaningless. |
| +// - An existing Content-Encoding means the origin already encoded it. |
| +// Compressing again produces a body no client can decode without knowing |
| +// we did it. |
| +// - Cache-Control: no-transform is the origin forbidding exactly this |
| +// (RFC 9110 §5.5). |
| +// |
| +// A MISSING Content-Type is refused rather than sniffed. Sniffing is the |
| +// stdlib's job and it runs on the bytes we would have already compressed — |
| +// it would read gzip's magic number and label the response application/x-gzip. |
| +func compressible(h http.Header, status int) bool { |
| + switch status { |
| + case http.StatusNoContent, http.StatusResetContent, |
| + http.StatusPartialContent, http.StatusNotModified: |
| + return false |
| + } |
| + if h.Get("Content-Range") != "" { |
| + return false |
| + } |
| + if h.Get("Content-Encoding") != "" { |
| + return false |
| + } |
| + if hasCCDirective(joinHeader(h, "Cache-Control"), "no-transform") { |
| + return false |
| + } |
| + if n, err := strconv.Atoi(h.Get("Content-Length")); err == nil && n < minCompressSize { |
| + return false |
| + } |
| + return compressibleType(h.Get("Content-Type")) |
| +} |
| + |
| +// compressibleType reports whether a media type is text underneath. text/* is |
| +// the bulk of it; the structured suffixes (+json, +xml) catch types nobody has |
| +// enumerated yet, which is why they are here rather than a longer switch. |
| +// |
| +// text/event-stream is the one text type excluded: it is an open stream a |
| +// client consumes event by event, and buffering it inside a compressor is a |
| +// hang, not a saving. |
| +func compressibleType(ct string) bool { |
| + mt, _, _ := strings.Cut(ct, ";") |
| + mt = strings.ToLower(strings.TrimSpace(mt)) |
| + if mt == "" || mt == "text/event-stream" { |
| + return false |
| + } |
| + if strings.HasPrefix(mt, "text/") || |
| + strings.HasSuffix(mt, "+json") || strings.HasSuffix(mt, "+xml") { |
| + return true |
| + } |
| + switch mt { |
| + case "application/json", "application/javascript", "application/x-javascript", |
| + "application/xml", "application/wasm": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// weakenETag downgrades a strong validator to a weak one, in place. A gzipped |
| +// body is a different representation of the same resource, so an ETag that |
| +// claims byte-for-byte equality with the identity body is a lie — and the one |
| +// thing that lie breaks is a Range request served against the wrong bytes. |
| +// Conditional requests keep working: If-None-Match compares weakly for GET, |
| +// so a client returning W/"abc" still matches the origin's "abc" and gets its |
| +// 304. |
| +func weakenETag(h http.Header) { |
| + if et := h.Get("ETag"); strings.HasPrefix(et, `"`) { |
| + h.Set("ETag", `W/`+et) |
| + } |
| +} |
diff --git a/internal/edge/compress_test.go b/internal/edge/compress_test.go| index 6ddda82e6..e68f92a0c 100644 |
| --- a/internal/edge/compress_test.go |
| +++ b/internal/edge/compress_test.go |
| @@ -71,3 +71,90 @@ func TestQValue(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// TestCompressibleEligibility walks the response-side gate. Each false case |
| +// is a response that would be WRONG or pointless to compress, not merely |
| +// unprofitable — a 206 range body, an already-encoded body, an origin that |
| +// said no-transform. |
| +func TestCompressibleEligibility(t *testing.T) { |
| + base := func() http.Header { |
| + h := http.Header{} |
| + h.Set("Content-Type", "text/css") |
| + return h |
| + } |
| + for _, tc := range []struct { |
| + name string |
| + mutate func(http.Header) |
| + status int |
| + want bool |
| + }{ |
| + {"plain css", func(http.Header) {}, 200, true}, |
| + {"html error page", func(h http.Header) { h.Set("Content-Type", "text/html; charset=utf-8") }, 404, true}, |
| + {"json api", func(h http.Header) { h.Set("Content-Type", "application/json") }, 200, true}, |
| + {"svg", func(h http.Header) { h.Set("Content-Type", "image/svg+xml") }, 200, true}, |
| + {"long body", func(h http.Header) { h.Set("Content-Length", "4096") }, 200, true}, |
| + {"unknown length", func(h http.Header) { h.Del("Content-Length") }, 200, true}, |
| + |
| + {"no content type", func(h http.Header) { h.Del("Content-Type") }, 200, false}, |
| + {"png", func(h http.Header) { h.Set("Content-Type", "image/png") }, 200, false}, |
| + {"event stream", func(h http.Header) { h.Set("Content-Type", "text/event-stream") }, 200, false}, |
| + {"already encoded", func(h http.Header) { h.Set("Content-Encoding", "gzip") }, 200, false}, |
| + {"no-transform", func(h http.Header) { h.Set("Cache-Control", "max-age=60, no-transform") }, 200, false}, |
| + {"short body", func(h http.Header) { h.Set("Content-Length", "512") }, 200, false}, |
| + {"exactly at threshold", func(h http.Header) { h.Set("Content-Length", "1024") }, 200, true}, |
| + {"partial content", func(h http.Header) { h.Set("Content-Range", "bytes 0-99/4096") }, 206, false}, |
| + {"no content", func(http.Header) {}, 204, false}, |
| + {"reset content", func(http.Header) {}, 205, false}, |
| + {"not modified", func(http.Header) {}, 304, false}, |
| + } { |
| + h := base() |
| + tc.mutate(h) |
| + if got := compressible(h, tc.status); got != tc.want { |
| + t.Errorf("%s: compressible(%v, %d) = %v, want %v", tc.name, h, tc.status, got, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// TestCompressibleTypeSuffixes: a media type we have never heard of still |
| +// compresses when its structured suffix says it is text underneath. |
| +func TestCompressibleTypeSuffixes(t *testing.T) { |
| + for _, ct := range []string{ |
| + "application/manifest+json", "application/atom+xml", "application/ld+json", |
| + "application/vnd.api+json", "text/plain", "application/javascript", |
| + "application/wasm", "application/xml", "text/css;charset=UTF-8", |
| + } { |
| + if !compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = false, want true", ct) |
| + } |
| + } |
| + for _, ct := range []string{ |
| + "", "image/png", "video/mp4", "application/octet-stream", |
| + "application/zip", "font/woff2", "text/event-stream", |
| + } { |
| + if compressibleType(ct) { |
| + t.Errorf("compressibleType(%q) = true, want false", ct) |
| + } |
| + } |
| +} |
| + |
| +// TestWeakenETag: a gzipped body is a different representation, so its |
| +// validator must not claim byte-equality with the identity one. Weakening |
| +// keeps conditional requests working — the stdlib's If-None-Match check |
| +// compares weakly for GET — while dropping the strong-equality claim a |
| +// Range request would rely on. |
| +func TestWeakenETag(t *testing.T) { |
| + for _, tc := range []struct{ in, want string }{ |
| + {`"abc123"`, `W/"abc123"`}, |
| + {`W/"abc123"`, `W/"abc123"`}, // already weak: untouched |
| + {"", ""}, // absent: nothing to weaken |
| + } { |
| + h := http.Header{} |
| + if tc.in != "" { |
| + h.Set("ETag", tc.in) |
| + } |
| + weakenETag(h) |
| + if got := h.Get("ETag"); got != tc.want { |
| + t.Errorf("weakenETag(%q) -> %q, want %q", tc.in, got, tc.want) |
| + } |
| + } |
| +} |