repos / pico

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

commit
272d185
parent
e20086a
author
Eric Bower
date
2026-04-19 11:20:46 -0400 EDT
fix(pgs): httpcache must return cached headers when returning 304
2 files changed,  +65, -1
M pkg/httpcache/cache_test.go
+53, -0
 1@@ -914,6 +914,59 @@ func readBody(resp *http.Response) (string, error) {
 2 	return string(buf), nil
 3 }
 4 
 5+// Regression: a 304 from cache validation must include the cached response
 6+// headers (ETag, Content-Type, Cache-Control, etc.) so the browser can match
 7+// the 304 to its local cached body. Without them browsers show a blank page.
 8+func TestCache304IncludesCachedHeaders(t *testing.T) {
 9+	logger := slog.Default()
10+	mux := http.NewServeMux()
11+	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
12+		w.Header().Set("etag", "\"abc\"")
13+		w.Header().Set("content-type", "text/html; charset=utf-8")
14+		w.Header().Set("cache-control", "max-age=300")
15+		w.WriteHeader(200)
16+		_, _ = w.Write([]byte("<h1>hello</h1>"))
17+	})
18+
19+	handler := NewHttpCache(logger, mux)
20+	tc := NewTestContext(t, handler)
21+	req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
22+
23+	// Populate cache with a fresh entry that has ETag, Content-Type, Cache-Control
24+	cacheKey := handler.GetCacheKey(req)
25+	cv := testCacheValue(10 * time.Second)
26+	cv.Header["ETag"] = []string{"\"abc\""}
27+	cv.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
28+	cv.Header["Cache-Control"] = []string{"max-age=300"}
29+	cv.Body = []byte("<h1>hello</h1>")
30+	cacheData, _ := json.Marshal(cv)
31+	handler.Cache.Add(cacheKey, cacheData)
32+
33+	// Send conditional request that triggers a 304 from the cache layer
34+	resp, _ := tc.DoWithHeaders(req, map[string][]string{
35+		"If-None-Match": {"\"abc\""},
36+	})
37+	if resp.StatusCode != http.StatusNotModified {
38+		t.Fatalf("expected 304, got %d", resp.StatusCode)
39+	}
40+
41+	// The 304 must carry the cached headers so the browser can use them
42+	// Note: Go's HTTP server strips Content-Type on 304 responses, which is fine
43+	// per RFC 9110 — the browser already has it from the original 200.
44+	if got := resp.Header.Get("ETag"); got != "\"abc\"" {
45+		t.Errorf("expected ETag %q, got %q", "\"abc\"", got)
46+	}
47+	if got := resp.Header.Get("Cache-Control"); got != "max-age=300" {
48+		t.Errorf("expected Cache-Control %q, got %q", "max-age=300", got)
49+	}
50+
51+	// Body must be empty per RFC 9110 15.4.5
52+	body, _ := readBody(resp)
53+	if body != "" {
54+		t.Errorf("expected empty body for 304, got %q", body)
55+	}
56+}
57+
58 func TestCacheAgeTtl(t *testing.T) {
59 	mux := http.NewServeMux()
60 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
M pkg/httpcache/serve.go
+12, -1
 1@@ -594,7 +594,18 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
 2 	if valid {
 3 		// RFC 9111 4.3.4 304 Not Modified
 4 		// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
 5-		w.Header().Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
 6+		// A 304 response must include headers the client needs to update
 7+		// its cached representation (ETag, Last-Modified, Cache-Control, etc.)
 8+		hdr := w.Header()
 9+		for key, values := range cacheValue.Header {
10+			if isForbiddenHeader(key) {
11+				continue
12+			}
13+			for _, value := range values {
14+				hdr.Add(key, value)
15+			}
16+		}
17+		hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
18 		w.WriteHeader(status)
19 		return nil
20 	}