Commit 272d185

Eric Bower  ·  2026-04-19 11:20:46 -0400 EDT
parent e20086a
fix(pgs): httpcache must return cached headers when returning 304
2 files changed,  +65, -1
+53, -0
......@@ -914,6 +914,59 @@ func readBody(resp *http.Response) (string, error) {
914914 return string(buf), nil
915915 }
916916
917+// Regression: a 304 from cache validation must include the cached response
918+// headers (ETag, Content-Type, Cache-Control, etc.) so the browser can match
919+// the 304 to its local cached body. Without them browsers show a blank page.
920+func TestCache304IncludesCachedHeaders(t *testing.T) {
921+ logger := slog.Default()
922+ mux := http.NewServeMux()
923+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
924+ w.Header().Set("etag", "\"abc\"")
925+ w.Header().Set("content-type", "text/html; charset=utf-8")
926+ w.Header().Set("cache-control", "max-age=300")
927+ w.WriteHeader(200)
928+ _, _ = w.Write([]byte("<h1>hello</h1>"))
929+ })
930+
931+ handler := NewHttpCache(logger, mux)
932+ tc := NewTestContext(t, handler)
933+ req, _ := http.NewRequest("GET", tc.cachedServer.URL+"/test", nil)
934+
935+ // Populate cache with a fresh entry that has ETag, Content-Type, Cache-Control
936+ cacheKey := handler.GetCacheKey(req)
937+ cv := testCacheValue(10 * time.Second)
938+ cv.Header["ETag"] = []string{"\"abc\""}
939+ cv.Header["Content-Type"] = []string{"text/html; charset=utf-8"}
940+ cv.Header["Cache-Control"] = []string{"max-age=300"}
941+ cv.Body = []byte("<h1>hello</h1>")
942+ cacheData, _ := json.Marshal(cv)
943+ handler.Cache.Add(cacheKey, cacheData)
944+
945+ // Send conditional request that triggers a 304 from the cache layer
946+ resp, _ := tc.DoWithHeaders(req, map[string][]string{
947+ "If-None-Match": {"\"abc\""},
948+ })
949+ if resp.StatusCode != http.StatusNotModified {
950+ t.Fatalf("expected 304, got %d", resp.StatusCode)
951+ }
952+
953+ // The 304 must carry the cached headers so the browser can use them
954+ // Note: Go's HTTP server strips Content-Type on 304 responses, which is fine
955+ // per RFC 9110 — the browser already has it from the original 200.
956+ if got := resp.Header.Get("ETag"); got != "\"abc\"" {
957+ t.Errorf("expected ETag %q, got %q", "\"abc\"", got)
958+ }
959+ if got := resp.Header.Get("Cache-Control"); got != "max-age=300" {
960+ t.Errorf("expected Cache-Control %q, got %q", "max-age=300", got)
961+ }
962+
963+ // Body must be empty per RFC 9110 15.4.5
964+ body, _ := readBody(resp)
965+ if body != "" {
966+ t.Errorf("expected empty body for 304, got %q", body)
967+ }
968+}
969+
917970 func TestCacheAgeTtl(t *testing.T) {
918971 mux := http.NewServeMux()
919972 mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+12, -1
......@@ -594,7 +594,18 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
594594 if valid {
595595 // RFC 9111 4.3.4 304 Not Modified
596596 // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
597- w.Header().Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
597+ // A 304 response must include headers the client needs to update
598+ // its cached representation (ETag, Last-Modified, Cache-Control, etc.)
599+ hdr := w.Header()
600+ for key, values := range cacheValue.Header {
601+ if isForbiddenHeader(key) {
602+ continue
603+ }
604+ for _, value := range values {
605+ hdr.Add(key, value)
606+ }
607+ }
608+ hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
598609 w.WriteHeader(status)
599610 return nil
600611 }