repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

commit
65a2f09
parent
66ac6b0
author
Eric Bower
date
2026-04-28 12:57:39 -0400 EDT
fix(httpcache): cdn accept encoding

Two bugs combined to produce the broken behavior:

Bug 1: CDN forwarded Accept-Encoding to upstream (cmd/pgs/cdn/main.go)

proxyServe.ServeHTTP cloned the incoming request (including its Accept-Encoding: zstd header sent by Caddy) and forwarded it to ash.pgs.sh. The upstream responded with a zstd-compressed body + content-encoding: zstd. The CDN then cached that compressed blob. Caddy (sitting in front of the CDN) can't transcode
zstd→nothing, so clients received raw zstd bytes named .xml.

Fix: proxyReq.Header.Del("Accept-Encoding") before the upstream round-trip. The CDN should store one uncompressed representation per URL; Caddy handles per-client content encoding on egress.

Bug 2: matchVary was looking in the wrong place (pkg/httpcache/serve.go, rw.go)

When the response included Vary: Accept-Encoding, matchVary looked for Accept-Encoding in the response headers map — but Accept-Encoding is a request header and was never there. The cachedValue == "" branch silently continued, causing every request to match regardless of what encoding it accepted.

Fix: ToCacheValue now accepts the *http.Request and snapshots the Vary-relevant request header values into a new CacheValue.VaryRequestHeaders map[string]string field. matchVary now compares the incoming request's headers against that snapshot instead of looking in response headers. Legacy entries with no
VaryRequestHeaders are treated as misses so they repopulate correctly.
4 files changed,  +57, -38
M cmd/pgs/cdn/main.go
+6, -0
 1@@ -86,6 +86,12 @@ func (p *proxyServe) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 2 	proxyReq.URL.Path = target.Path
 3 	proxyReq.URL.RawQuery = target.RawQuery
 4 	proxyReq.RequestURI = ""
 5+	// Prevent the upstream from returning a compressed body. The CDN cache
 6+	// stores a single representation per URL; Caddy handles per-client
 7+	// encoding on the way out. If we forward Accept-Encoding, origin may
 8+	// return zstd-compressed bytes that get cached and then served to
 9+	// clients that never requested zstd.
10+	proxyReq.Header.Del("Accept-Encoding")
11 	// Preserve the original Host header so ash.pgs.sh routes correctly.
12 	proxyReq.Host = req.Host
13 
M pkg/httpcache/cache_test.go
+3, -2
 1@@ -242,8 +242,9 @@ func TestCacheVary(t *testing.T) {
 2 	cacheKey := handler.GetCacheKey(req)
 3 	cv := testCacheValue(250 * time.Second)
 4 	cv.Header["Vary"] = []string{"Accept-Encoding"}
 5-	// Store the original request header that selected this representation.
 6-	cv.Header["Accept-Encoding"] = []string{"gzip"}
 7+	// VaryRequestHeaders snapshots the request header values that were present
 8+	// when this entry was cached, keyed by the lowercase header name.
 9+	cv.VaryRequestHeaders = map[string]string{"accept-encoding": "gzip"}
10 	cacheValue, _ := json.Marshal(cv)
11 	handler.Cache.Add(cacheKey, cacheValue)
12 
M pkg/httpcache/rw.go
+25, -9
 1@@ -43,26 +43,42 @@ func (rw *responseWriter) Send() {
 2 	_, _ = rw.ResponseWriter.Write(rw.body)
 3 }
 4 
 5-func (rw *responseWriter) ToCacheValue() *CacheValue {
 6+func (rw *responseWriter) ToCacheValue(r *http.Request) *CacheValue {
 7 	// Normalize header keys to lowercase to avoid case-sensitivity issues
 8 	// in the cached map (e.g., "ETag" vs "Etag" as separate keys).
 9 	headers := make(map[string][]string)
10 	for k, v := range rw.Header() {
11 		headers[strings.ToLower(k)] = v
12 	}
13+
14+	// Snapshot the request header values named by the response Vary header so
15+	// matchVary can compare them on future cache lookups (Vary lists request
16+	// header names, not response header names).
17+	varyReqHdrs := make(map[string]string)
18+	if vary := headers["vary"]; len(vary) > 0 {
19+		for _, field := range strings.FieldsFunc(vary[0], func(c rune) bool { return c == ',' }) {
20+			field = strings.TrimSpace(strings.ToLower(field))
21+			if field != "" && field != "*" {
22+				varyReqHdrs[field] = r.Header.Get(field)
23+			}
24+		}
25+	}
26+
27 	cv := &CacheValue{
28-		Header:     headers,
29-		Body:       rw.body,
30-		CreatedAt:  time.Now(),
31-		StatusCode: rw.StatusCode(),
32+		Header:             headers,
33+		Body:               rw.body,
34+		CreatedAt:          time.Now(),
35+		StatusCode:         rw.StatusCode(),
36+		VaryRequestHeaders: varyReqHdrs,
37 	}
38 
39 	return cv
40 }
41 
42 type CacheValue struct {
43-	Header     map[string][]string `json:"headers"`
44-	Body       []byte              `json:"body"`
45-	CreatedAt  time.Time           `json:"created_at"`
46-	StatusCode int                 `json:"status_code"`
47+	Header             map[string][]string `json:"headers"`
48+	Body               []byte              `json:"body"`
49+	CreatedAt          time.Time           `json:"created_at"`
50+	StatusCode         int                 `json:"status_code"`
51+	VaryRequestHeaders map[string]string   `json:"vary_request_headers,omitempty"`
52 }
M pkg/httpcache/serve.go
+23, -27
 1@@ -186,7 +186,7 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 2 	err = isResponseCachable(r, wrapped)
 3 	if err == nil {
 4 		log.Info("storing cache")
 5-		nextValue := wrapped.ToCacheValue()
 6+		nextValue := wrapped.ToCacheValue(r)
 7 		enc, _ := json.Marshal(nextValue)
 8 		c.Cache.Remove(cacheKey)
 9 		c.Cache.Add(cacheKey, enc)
10@@ -227,47 +227,43 @@ func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string,
11 	_, _ = w.Write(cacheValue.Body)
12 }
13 
14-// matchVary checks if the request matches the Vary header from the cached response
15+// matchVary checks if the request matches the Vary header from the cached response.
16 // RFC 9111 4.1 Vary.
17-func matchVary(r *http.Request, cachedHeaders map[string][]string) bool {
18-	vary := getHeader(cachedHeaders, "Vary")
19+//
20+// Vary lists *request* header names, so we compare the incoming request headers
21+// against the request header values that were snapshotted when the entry was
22+// stored (CacheValue.VaryRequestHeaders). Comparing against response headers
23+// (the old approach) silently skipped every field because request header names
24+// never appear in response header maps.
25+func matchVary(r *http.Request, cacheValue *CacheValue) bool {
26+	vary := getHeader(cacheValue.Header, "Vary")
27 	if vary == "" {
28 		return true
29 	}
30 
31-	// Vary: * means the response is not cacheable
32+	// Vary: * means the response is not cacheable by a shared cache.
33 	if vary == "*" {
34 		return false
35 	}
36 
37-	// Parse Vary header and check each field
38-	fields := strings.FieldsFunc(vary, func(r rune) bool {
39-		return r == ','
40-	})
41+	// If the entry predates VaryRequestHeaders (legacy/zero value), fall back to
42+	// treating it as a miss so the cache re-populates with a fresh entry.
43+	if len(cacheValue.VaryRequestHeaders) == 0 {
44+		return false
45+	}
46 
47+	fields := strings.FieldsFunc(vary, func(c rune) bool { return c == ',' })
48 	for _, field := range fields {
49 		field = strings.TrimSpace(strings.ToLower(field))
50 		if field == "" {
51 			continue
52 		}
53-
54-		// Get the request header value
55-		reqValue := r.Header.Get(field)
56-
57-		// Get the cached header value for this field (case-insensitive lookup)
58-		var cachedValue string
59-		for key, values := range cachedHeaders {
60-			if strings.ToLower(key) == field && len(values) > 0 {
61-				cachedValue = values[0]
62-				break
63-			}
64-		}
65-		if cachedValue == "" {
66-			continue
67+		cachedReqVal, known := cacheValue.VaryRequestHeaders[field]
68+		if !known {
69+			// Field listed in Vary but not recorded — treat as miss.
70+			return false
71 		}
72-
73-		// Compare values - must match exactly
74-		if reqValue != cachedValue {
75+		if r.Header.Get(field) != cachedReqVal {
76 			return false
77 		}
78 	}
79@@ -562,7 +558,7 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
80 	}
81 
82 	// RFC 9111 4.1 Vary - check if request matches cached Vary values
83-	if !matchVary(r, cacheValue.Header) {
84+	if !matchVary(r, &cacheValue) {
85 		return fmt.Errorf("vary mismatch")
86 	}
87