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) { | |
| 765 | 765 | cacheData, _ := json.Marshal(staleCv) | |
| 766 | 766 | handler.Cache.Add(cacheKey, cacheData) | |
| 767 | 767 | ||
| 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. | |
| 769 | 772 | resp1, _ := tc.DoWithHeaders(req, map[string][]string{ | |
| 770 | 773 | "If-None-Match": {"\"abc\""}, | |
| 771 | 774 | }) | |
| 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) | |
| 774 | 777 | } | |
| 775 | 778 | status := resp1.Header.Get("cache-status") | |
| 776 | 779 | if !strings.Contains(status, "hit") { |
| ... | ... | @@ -889,14 +892,15 @@ func TestCache304NoBody(t *testing.T) { | |
| 889 | 892 | cacheData, _ := json.Marshal(cv) | |
| 890 | 893 | handler.Cache.Add(cacheKey, cacheData) | |
| 891 | 894 | ||
| 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. | |
| 893 | 897 | 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) | |
| 896 | 900 | } | |
| 897 | 901 | 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) | |
| 900 | 904 | } | |
| 901 | 905 | } | |
| 902 | 906 |
| ... | ... | @@ -1075,8 +1079,10 @@ func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) { | |
| 1075 | 1079 | ||
| 1076 | 1080 | resp, _ := tc.Do(req) | |
| 1077 | 1081 | ||
| 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) | |
| 1080 | 1086 | } | |
| 1081 | 1087 | status := resp.Header.Get("cache-status") | |
| 1082 | 1088 | if !strings.Contains(status, "hit") { |
+62,
-15
| ... | ... | @@ -90,6 +90,12 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 90 | 90 | ||
| 91 | 91 | // RFC 9111 4.2.4 + 4.3.1/4.3.2: stale must-revalidate entries must be | |
| 92 | 92 | // 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 | + | ||
| 93 | 99 | if err.Error() == "cache is stale and must-revalidate requires revalidation" { | |
| 94 | 100 | if cachedData, exists := c.Cache.Get(cacheKey); exists { | |
| 95 | 101 | var cachedValue CacheValue |
| ... | ... | @@ -114,22 +120,58 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 114 | 120 | if wrapped.StatusCode() == http.StatusNotModified { | |
| 115 | 121 | log.Info("304 not modified, updating cached headers") | |
| 116 | 122 | 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 | + | } | |
| 123 | 163 | } | |
| 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 | |
| 129 | 169 | } | |
| 130 | 170 | } | |
| 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) | |
| 133 | 175 | return | |
| 134 | 176 | } | |
| 135 | 177 |
| ... | ... | @@ -154,8 +196,8 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 154 | 196 | func isForbiddenHeader(key string) bool { | |
| 155 | 197 | switch strings.ToLower(key) { | |
| 156 | 198 | 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": | |
| 159 | 201 | return true | |
| 160 | 202 | default: | |
| 161 | 203 | return false |
| ... | ... | @@ -178,6 +220,9 @@ func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string, | |
| 178 | 220 | age := ageDur.Seconds() | |
| 179 | 221 | hdr.Add("age", strconv.Itoa(int(age)+1)) | |
| 180 | 222 | hdr.Add("cache-status", cacheStatusHit(cacheKey, freshness.Seconds())) | |
| 223 | + | if cacheValue.StatusCode != 0 && cacheValue.StatusCode != http.StatusOK { | |
| 224 | + | w.WriteHeader(cacheValue.StatusCode) | |
| 225 | + | } | |
| 181 | 226 | _, _ = w.Write(cacheValue.Body) | |
| 182 | 227 | } | |
| 183 | 228 |
| ... | ... | @@ -605,6 +650,8 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt | |
| 605 | 650 | hdr.Add(key, value) | |
| 606 | 651 | } | |
| 607 | 652 | } | |
| 653 | + | ageDur := calcAge(cacheValue.CreatedAt) | |
| 654 | + | hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1)) | |
| 608 | 655 | hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds())) | |
| 609 | 656 | w.WriteHeader(status) | |
| 610 | 657 | return nil |