Commit d758032

Eric Bower  ·  2026-04-19 11:32:48 -0400 EDT
parent 272d185
fix(pgs): 304 send correct status and headers
2 files changed,  +78, -25
+16, -10
......@@ -765,12 +765,15 @@ func TestCache304NotModifiedMerge(t *testing.T) {
765765 cacheData, _ := json.Marshal(staleCv)
766766 handler.Cache.Add(cacheKey, cacheData)
767767
768- // First request with If-None-Match triggers validation; origin returns 304
768+ // First request with If-None-Match triggers validation; origin returns 304.
769+ // Client sent conditional headers, so if they still match the updated cache
770+ // entry, the client gets 304. Here the upstream updated the ETag to "abc-updated"
771+ // so the client's If-None-Match "abc" no longer matches — serve cached body as 200.
769772 resp1, _ := tc.DoWithHeaders(req, map[string][]string{
770773 "If-None-Match": {"\"abc\""},
771774 })
772- if resp1.StatusCode != http.StatusNotModified {
773- t.Errorf("expected 304, got %d", resp1.StatusCode)
775+ if resp1.StatusCode != http.StatusOK {
776+ t.Errorf("expected 200 (ETag changed after revalidation), got %d", resp1.StatusCode)
774777 }
775778 status := resp1.Header.Get("cache-status")
776779 if !strings.Contains(status, "hit") {
......@@ -889,14 +892,15 @@ func TestCache304NoBody(t *testing.T) {
889892 cacheData, _ := json.Marshal(cv)
890893 handler.Cache.Add(cacheKey, cacheData)
891894
892- // Trigger revalidation — upstream returns 304 with a spurious body
895+ // Trigger revalidation — upstream returns 304 with a spurious body.
896+ // Client request is unconditional, so cache serves the stored body as 200.
893897 resp, _ := tc.Do(req)
894- if resp.StatusCode != http.StatusNotModified {
895- t.Fatalf("expected 304, got %d", resp.StatusCode)
898+ if resp.StatusCode != http.StatusOK {
899+ t.Fatalf("expected 200, got %d", resp.StatusCode)
896900 }
897901 body, _ := readBody(resp)
898- if body != "" {
899- t.Errorf("expected empty body for 304 response, got %q", body)
902+ if body != "original body" {
903+ t.Errorf("expected cached body 'original body', got %q", body)
900904 }
901905 }
902906
......@@ -1075,8 +1079,10 @@ func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
10751079
10761080 resp, _ := tc.Do(req)
10771081
1078- if resp.StatusCode != http.StatusNotModified {
1079- t.Errorf("expected 304, got %d", resp.StatusCode)
1082+ // Client request is unconditional — after upstream 304, cache
1083+ // serves the stored body as 200.
1084+ if resp.StatusCode != http.StatusOK {
1085+ t.Errorf("expected 200, got %d", resp.StatusCode)
10801086 }
10811087 status := resp.Header.Get("cache-status")
10821088 if !strings.Contains(status, "hit") {
+62, -15
......@@ -90,6 +90,12 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
9090
9191 // RFC 9111 4.2.4 + 4.3.1/4.3.2: stale must-revalidate entries must be
9292 // revalidated with conditional headers derived from the stored response.
93+ // Preserve original client conditional headers so we can evaluate them
94+ // after revalidation to decide whether the client gets 304 or 200.
95+ clientIfNoneMatch := r.Header.Get("If-None-Match")
96+ clientIfModifiedSince := r.Header.Get("If-Modified-Since")
97+ clientConditional := clientIfNoneMatch != "" || clientIfModifiedSince != ""
98+
9399 if err.Error() == "cache is stale and must-revalidate requires revalidation" {
94100 if cachedData, exists := c.Cache.Get(cacheKey); exists {
95101 var cachedValue CacheValue
......@@ -114,22 +120,58 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
114120 if wrapped.StatusCode() == http.StatusNotModified {
115121 log.Info("304 not modified, updating cached headers")
116122 existingData, exists := c.Cache.Get(cacheKey)
117- if exists {
118- var cacheValue CacheValue
119- if json.Unmarshal(existingData, &cacheValue) == nil {
120- // Merge headers from the 304 response into the cached entry.
121- for key, values := range wrapped.Header() {
122- cacheValue.Header[key] = values
123+ if !exists {
124+ // Cache entry vanished; forward the 304 as-is.
125+ wrapped.Send()
126+ return
127+ }
128+
129+ var cacheValue CacheValue
130+ if json.Unmarshal(existingData, &cacheValue) != nil {
131+ wrapped.Send()
132+ return
133+ }
134+
135+ // Merge non-forbidden headers from the 304 response into the cached entry.
136+ for key, values := range wrapped.Header() {
137+ if isForbiddenHeader(key) {
138+ continue
139+ }
140+ cacheValue.Header[key] = values
141+ }
142+ // Revalidation refreshes the entry — reset CreatedAt so it's fresh again.
143+ cacheValue.CreatedAt = time.Now()
144+ enc, _ := json.Marshal(cacheValue)
145+ c.Cache.Add(cacheKey, enc)
146+ c.AddCacheItem(float64(len(enc)))
147+
148+ if clientConditional {
149+ // Client sent conditional headers — re-evaluate against the
150+ // updated cached entry and return 304 if it still matches.
151+ r.Header.Set("If-None-Match", clientIfNoneMatch)
152+ r.Header.Set("If-Modified-Since", clientIfModifiedSince)
153+ valid, status := c.handleValidation(r, &cacheValue)
154+ if valid {
155+ hdr := w.Header()
156+ for key, values := range cacheValue.Header {
157+ if isForbiddenHeader(key) {
158+ continue
159+ }
160+ for _, value := range values {
161+ hdr.Add(key, value)
162+ }
123163 }
124- // Revalidation refreshes the entry — reset CreatedAt so it's fresh again.
125- cacheValue.CreatedAt = time.Now()
126- enc, _ := json.Marshal(cacheValue)
127- c.Cache.Add(cacheKey, enc)
128- c.AddCacheItem(float64(len(enc)))
164+ ageDur := calcAge(cacheValue.CreatedAt)
165+ hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
166+ hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
167+ w.WriteHeader(status)
168+ return
129169 }
130170 }
131- wrapped.Header().Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
132- wrapped.Send()
171+
172+ // Client request was unconditional (or conditional but no longer matches) —
173+ // serve the full cached response.
174+ serveCache(w, c.Ttl, cacheKey, &cacheValue)
133175 return
134176 }
135177
......@@ -154,8 +196,8 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
154196 func isForbiddenHeader(key string) bool {
155197 switch strings.ToLower(key) {
156198 case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
157- "teardown", "transfer-encoding", "upgrade", "proxy-connection",
158- "www-authenticate", "proxy-authentication-info":
199+ "te", "trailer", "transfer-encoding", "upgrade", "proxy-connection",
200+ "proxy-authentication-info":
159201 return true
160202 default:
161203 return false
......@@ -178,6 +220,9 @@ func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string,
178220 age := ageDur.Seconds()
179221 hdr.Add("age", strconv.Itoa(int(age)+1))
180222 hdr.Add("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
223+ if cacheValue.StatusCode != 0 && cacheValue.StatusCode != http.StatusOK {
224+ w.WriteHeader(cacheValue.StatusCode)
225+ }
181226 _, _ = w.Write(cacheValue.Body)
182227 }
183228
......@@ -605,6 +650,8 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
605650 hdr.Add(key, value)
606651 }
607652 }
653+ ageDur := calcAge(cacheValue.CreatedAt)
654+ hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
608655 hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
609656 w.WriteHeader(status)
610657 return nil