- commit
- f46c23c
- parent
- 8045405
- author
- Eric Bower
- date
- 2026-04-21 22:57:35 -0400 EDT
chore(httpcache): cache-status cleanup and random bug fixes
2 files changed,
+53,
-80
+3,
-3
1@@ -772,11 +772,11 @@ func TestCache304NotModifiedMerge(t *testing.T) {
2 resp1, _ := tc.DoWithHeaders(req, map[string][]string{
3 "If-None-Match": {"\"abc\""},
4 })
5- if resp1.StatusCode != http.StatusOK {
6- t.Errorf("expected 200 (ETag changed after revalidation), got %d", resp1.StatusCode)
7+ if resp1.StatusCode != http.StatusNotModified {
8+ t.Errorf("expected 304 (ETag changed after revalidation), got %d", resp1.StatusCode)
9 }
10 status := resp1.Header.Get("cache-status")
11- if !strings.Contains(status, "hit") {
12+ if !strings.Contains(status, "fwd=stale") {
13 t.Errorf("expected cache-status hit, got %s", status)
14 }
15
+50,
-77
1@@ -2,6 +2,7 @@ package httpcache
2
3 import (
4 "encoding/json"
5+ "errors"
6 "fmt"
7 "log/slog"
8 "net/http"
9@@ -12,6 +13,8 @@ import (
10 "github.com/hashicorp/golang-lru/v2/expirable"
11 )
12
13+var ErrMustRevalidate = errors.New("cache is stale and must-revalidate requires revalidation")
14+
15 type CacheKey interface {
16 GetCacheKey(r *http.Request) string
17 }
18@@ -99,19 +102,19 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
19 // revalidated with conditional headers derived from the stored response.
20 // Preserve original client conditional headers so we can evaluate them
21 // after revalidation to decide whether the client gets 304 or 200.
22- clientIfNoneMatch := r.Header.Get("If-None-Match")
23- clientIfModifiedSince := r.Header.Get("If-Modified-Since")
24+ clientIfNoneMatch := r.Header.Get("if-none-match")
25+ clientIfModifiedSince := r.Header.Get("if-modified-since")
26 clientConditional := clientIfNoneMatch != "" || clientIfModifiedSince != ""
27
28- if err.Error() == "cache is stale and must-revalidate requires revalidation" {
29+ if errors.Is(err, ErrMustRevalidate) {
30 if cachedData, exists := c.Cache.Get(cacheKey); exists {
31 var cachedValue CacheValue
32 if json.Unmarshal(cachedData, &cachedValue) == nil {
33- if etag := getHeader(cachedValue.Header, "ETag"); etag != "" {
34- r.Header.Set("If-None-Match", etag)
35+ if etag := getHeader(cachedValue.Header, "etag"); etag != "" {
36+ r.Header.Set("if-none-match", etag)
37 }
38- if lastMod := getHeader(cachedValue.Header, "Last-Modified"); lastMod != "" {
39- r.Header.Set("If-Modified-Since", lastMod)
40+ if lastMod := getHeader(cachedValue.Header, "last-modified"); lastMod != "" {
41+ r.Header.Set("if-modified-since", lastMod)
42 }
43 }
44 }
45@@ -157,21 +160,15 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
46 if clientConditional {
47 // Client sent conditional headers -- re-evaluate against the
48 // updated cached entry and return 304 if it still matches.
49- r.Header.Set("If-None-Match", clientIfNoneMatch)
50- r.Header.Set("If-Modified-Since", clientIfModifiedSince)
51- valid, status := c.handleValidation(r, &cacheValue)
52+ r.Header.Set("if-none-match", clientIfNoneMatch)
53+ r.Header.Set("if-modified-since", clientIfModifiedSince)
54+ valid := c.handleValidation(r, &cacheValue)
55 if valid {
56- hdr := w.Header()
57- for key, values := range cacheValue.Header {
58- if isForbiddenHeader(key) {
59- continue
60- }
61- hdr[key] = values
62- }
63+ hdr := stripForbiddenHeaders(w, &cacheValue)
64 ageDur := calcAge(cacheValue.CreatedAt)
65 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
66- hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
67- w.WriteHeader(status)
68+ hdr.Set("cache-status", cacheStatusStale(cacheKey, wrapped.StatusCode()))
69+ w.WriteHeader(http.StatusNotModified)
70 return
71 }
72 }
73@@ -213,15 +210,7 @@ func isForbiddenHeader(key string) bool {
74 }
75
76 func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string, cacheValue *CacheValue) {
77- hdr := w.Header()
78- for key, values := range cacheValue.Header {
79- // RFC 9111 3.1 - Skip forbidden headers
80- if isForbiddenHeader(key) {
81- continue
82- }
83- hdr[key] = values
84- }
85-
86+ hdr := stripForbiddenHeaders(w, cacheValue)
87 ageDur := calcAge(cacheValue.CreatedAt)
88 age := ageDur.Seconds()
89 hdr.Set("age", strconv.Itoa(int(age)+1))
90@@ -293,30 +282,12 @@ func getHeader(headers map[string][]string, key string) string {
91 // handleValidation handles conditional request validation.
92 // RFC 9110 13 Conditional Requests.
93 // RFC 9111 4.3.2 Response Validation.
94-func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (bool, int) {
95- // Get ETag and Last-Modified with case-insensitive lookup
96- var etag string
97- var lastModified string
98- for key, values := range cacheValue.Header {
99- lowerKey := strings.ToLower(key)
100- c.Logger.Debug(
101- "validate",
102- "key", key,
103- "lowerKey", lowerKey,
104- "values", values,
105- "etag", etag,
106- "lastModified", lastModified,
107- )
108- if lowerKey == "etag" && len(values) > 0 {
109- etag = values[0]
110- }
111- if lowerKey == "last-modified" && len(values) > 0 {
112- lastModified = values[0]
113- }
114- }
115+func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) bool {
116+ etag := getHeader(cacheValue.Header, "etag")
117+ lastModified := getHeader(cacheValue.Header, "last-modified")
118
119 c.Logger.Debug(
120- "validate result",
121+ "validate",
122 "etag", etag,
123 "lastModified", lastModified,
124 )
125@@ -327,20 +298,17 @@ func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (b
126 if ifNoneMatch != "" {
127 // Wildcard If-None-Match: *
128 if ifNoneMatch == "*" {
129- if etag != "" {
130- return true, http.StatusNotModified
131- }
132- return false, 0
133+ return etag != ""
134 }
135
136 // Check if any of the provided ETags match
137 etags := parseETags(ifNoneMatch)
138 for _, etagVal := range etags {
139 if etagVal == etag {
140- return true, http.StatusNotModified
141+ return true
142 }
143 }
144- return false, 0
145+ return false
146 }
147
148 // RFC 9110 13.1.3 If-Modified-Since
149@@ -352,7 +320,7 @@ func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (b
150 cachedTime := parseTimeFallback(lastModified)
151 if !cachedTime.IsZero() {
152 if !cachedTime.After(reqTime) {
153- return true, http.StatusNotModified
154+ return true
155 }
156 }
157 }
158@@ -371,13 +339,13 @@ func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (b
159 // Cached response is not modified since the request time
160 // We can serve from cache, but don't return 304
161 // The caller will handle the cache hit
162- return false, 0
163+ return false
164 }
165 }
166 }
167 }
168
169- return false, 0
170+ return false
171 }
172
173 func parseETags(etags string) []string {
174@@ -628,7 +596,7 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
175 age := calcAge(cacheValue.CreatedAt)
176 freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
177 if freshness <= 0 {
178- return fmt.Errorf("cache is stale and must-revalidate requires revalidation")
179+ return ErrMustRevalidate
180 }
181 }
182
183@@ -640,32 +608,22 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
184 return fmt.Errorf("cache has no-store")
185 }
186
187+ age := calcAge(cacheValue.CreatedAt)
188+ freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
189+
190 // RFC 9111 4.3 Validation - check validation headers first
191 // RFC 9110 13 Conditional Requests
192 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13
193- valid, status := c.handleValidation(r, &cacheValue)
194+ valid := c.handleValidation(r, &cacheValue)
195 if valid {
196- // RFC 9111 4.3.4 304 Not Modified
197- // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
198- // A 304 response must include headers the client needs to update
199- // its cached representation (ETag, Last-Modified, Cache-Control, etc.)
200- hdr := w.Header()
201- for key, values := range cacheValue.Header {
202- if isForbiddenHeader(key) {
203- continue
204- }
205- hdr[key] = values
206- }
207+ hdr := stripForbiddenHeaders(w, &cacheValue)
208 ageDur := calcAge(cacheValue.CreatedAt)
209 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
210- hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
211- w.WriteHeader(status)
212+ hdr.Set("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
213+ w.WriteHeader(http.StatusNotModified)
214 return nil
215 }
216
217- age := calcAge(cacheValue.CreatedAt)
218- freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
219-
220 // Check if request allows stale responses (max-stale)
221 // RFC 9111 5.2.1.2 - max-stale allows serving stale responses
222 // We need to check this before the freshness <= 0 check
223@@ -758,6 +716,10 @@ func cacheStatusHit(cacheKey string, ttl float64) string {
224 return fmt.Sprintf("pico; hit; ttl=%d; key=%s", int(ttl), cacheKey)
225 }
226
227+func cacheStatusStale(cacheKey string, originStatus int) string {
228+ return fmt.Sprintf("pico; fwd=stale; fwd-status=%d", originStatus)
229+}
230+
231 func cacheStatusMiss(cacheKey string, stored bool) string {
232 // RFC 9211 2.2 Cache-Status fwd
233 // https://www.rfc-editor.org/rfc/rfc9211#section-2.2
234@@ -772,3 +734,14 @@ func cacheStatusMiss(cacheKey string, stored bool) string {
235 status = fmt.Sprintf("%s; key=%s", status, cacheKey)
236 return status
237 }
238+
239+func stripForbiddenHeaders(w http.ResponseWriter, cacheValue *CacheValue) http.Header {
240+ hdr := w.Header()
241+ for key, values := range cacheValue.Header {
242+ if isForbiddenHeader(key) {
243+ continue
244+ }
245+ hdr[key] = values
246+ }
247+ return hdr
248+}