- 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
+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) {
+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 }