carlos / platform Public

edge: Accept-Encoding gzip negotiation

q-valued token list, not a substring search: gzip;q=0 is a client
refusing gzip, and a specific token outranks the wildcard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NoqzkUVahW5ykubsBQitxc
Paul Campbell d80b26945e14363c64d24e7a176afaeb892f3099 parent d06ac3d
2 files changed, +136 −0
  • internal/edge/compress.go +63 −0
  • internal/edge/compress_test.go +73 −0
diff --git a/internal/edge/compress.go b/internal/edge/compress.go
new file mode 100644
index 000000000..78e2ca286
--- /dev/null
+++ b/internal/edge/compress.go
@@ -0,0 +1,63 @@
+// Edge response compression: one gzip ResponseWriter wrapper applied at the
+// client-write boundary in Edge.ServeHTTP, so every serving class — cache
+// hits, proxied misses, static trees, the status host — compresses through a
+// single site. Apps ship identity bytes; transport is the platform's problem.
+//
+// The wrapper sits BELOW recordingResponseWriter and ABOVE countingWriter:
+// the cache keeps storing identity bodies (one variant, compressed after
+// retrieval on every delivery) and the usage meter counts what actually
+// crossed the wire.
+package edge
+
+import (
+ "net/http"
+ "strconv"
+ "strings"
+)
+
+// acceptsGzip reports whether r's Accept-Encoding admits gzip. Parsed as the
+// q-valued token list RFC 9110 §12.5.3 defines rather than a substring search:
+// "gzip;q=0" is a client REFUSING gzip, and answering it with a compressed
+// body is a broken response, not a missed optimization.
+//
+// A specific token outranks the wildcard, so the first gzip token decides —
+// "gzip;q=0, *" is a refusal. The wildcard only speaks when gzip is unnamed.
+//
+// Read through joinHeader, not Header.Get: a field may legally arrive on
+// several lines, and Get returns only the first (the same class of bug
+// joinHeader's doc comment describes for Vary and Cache-Control).
+func acceptsGzip(h http.Header) bool {
+ var wildcard, wildcardOK bool
+ for _, part := range strings.Split(joinHeader(h, "Accept-Encoding"), ",") {
+ token, params, _ := strings.Cut(strings.TrimSpace(part), ";")
+ switch strings.ToLower(strings.TrimSpace(token)) {
+ case "gzip", "x-gzip":
+ return qValue(params) > 0
+ case "*":
+ wildcard, wildcardOK = true, qValue(params) > 0
+ }
+ }
+ if wildcard {
+ return wildcardOK
+ }
+ return false
+}
+
+// qValue reads the q parameter out of one Accept-Encoding element's parameter
+// string ("q=0.5", "q=0"). Absent or malformed means q=1: a client whose
+// header we cannot fully parse is treated as accepting, which is the same
+// default the stdlib's own negotiation takes.
+func qValue(params string) float64 {
+ for _, p := range strings.Split(params, ";") {
+ name, val, ok := strings.Cut(strings.TrimSpace(p), "=")
+ if !ok || !strings.EqualFold(strings.TrimSpace(name), "q") {
+ continue
+ }
+ f, err := strconv.ParseFloat(strings.TrimSpace(val), 64)
+ if err != nil {
+ return 1
+ }
+ return f
+ }
+ return 1
+}
diff --git a/internal/edge/compress_test.go b/internal/edge/compress_test.go
new file mode 100644
index 000000000..6ddda82e6
--- /dev/null
+++ b/internal/edge/compress_test.go
@@ -0,0 +1,73 @@
+package edge
+
+import (
+ "net/http"
+ "testing"
+)
+
+// TestAcceptsGzip pins the q-value rules. The two that matter in production
+// are `gzip;q=0` (a client explicitly refusing gzip — some corporate proxies
+// and old curl invocations send it) and a bare `*`, which admits gzip only
+// when its own q is non-zero. A specific token always outranks the wildcard,
+// so `gzip;q=0, *` is a refusal however generous the wildcard is.
+func TestAcceptsGzip(t *testing.T) {
+ for _, tc := range []struct {
+ ae string
+ want bool
+ }{
+ {"", false},
+ {"gzip", true},
+ {"GZIP", true},
+ {"x-gzip", true},
+ {"gzip, deflate, br", true},
+ {" gzip ; q=0.5 ", true},
+ {"gzip;q=0", false},
+ {"gzip;q=0.0", false},
+ {"gzip;q=0, *", false},
+ {"identity", false},
+ {"deflate, br", false},
+ {"*", true},
+ {"*;q=0", false},
+ {"deflate, *;q=0.1", true},
+ } {
+ h := http.Header{}
+ if tc.ae != "" {
+ h.Set("Accept-Encoding", tc.ae)
+ }
+ if got := acceptsGzip(h); got != tc.want {
+ t.Errorf("acceptsGzip(%q) = %v, want %v", tc.ae, got, tc.want)
+ }
+ }
+}
+
+// TestAcceptsGzipReadsEveryLine: Accept-Encoding may arrive on several lines
+// and that is identical in meaning to one comma-joined line (RFC 9110 §5.3).
+// Reading it with Header.Get would see only the first, which is the exact bug
+// joinHeader exists to prevent for Vary and Cache-Control.
+func TestAcceptsGzipReadsEveryLine(t *testing.T) {
+ h := http.Header{}
+ h.Add("Accept-Encoding", "deflate")
+ h.Add("Accept-Encoding", "gzip")
+ if !acceptsGzip(h) {
+ t.Fatal("gzip on a second Accept-Encoding line was invisible — read every line via joinHeader, not Get")
+ }
+}
+
+func TestQValue(t *testing.T) {
+ for _, tc := range []struct {
+ params string
+ want float64
+ }{
+ {"", 1},
+ {"q=1", 1},
+ {"q=0", 0},
+ {"q=0.5", 0.5},
+ {" Q = 0.25 ", 0.25},
+ {"q=bogus", 1}, // malformed q is ignored, not treated as a refusal
+ {"charset=utf-8", 1},
+ } {
+ if got := qValue(tc.params); got != tc.want {
+ t.Errorf("qValue(%q) = %v, want %v", tc.params, got, tc.want)
+ }
+ }
+}