Commit f46c23c

Eric Bower  ·  2026-04-21 22:57:35 -0400 EDT
parent 8045405
chore(httpcache): cache-status cleanup and random bug fixes
2 files changed,  +53, -80
+3, -3
......@@ -772,11 +772,11 @@ func TestCache304NotModifiedMerge(t *testing.T) {
772772 resp1, _ := tc.DoWithHeaders(req, map[string][]string{
773773 "If-None-Match": {"\"abc\""},
774774 })
775- if resp1.StatusCode != http.StatusOK {
776- t.Errorf("expected 200 (ETag changed after revalidation), got %d", resp1.StatusCode)
775+ if resp1.StatusCode != http.StatusNotModified {
776+ t.Errorf("expected 304 (ETag changed after revalidation), got %d", resp1.StatusCode)
777777 }
778778 status := resp1.Header.Get("cache-status")
779- if !strings.Contains(status, "hit") {
779+ if !strings.Contains(status, "fwd=stale") {
780780 t.Errorf("expected cache-status hit, got %s", status)
781781 }
782782
+50, -77
......@@ -2,6 +2,7 @@ package httpcache
22
33 import (
44 "encoding/json"
5+ "errors"
56 "fmt"
67 "log/slog"
78 "net/http"
......@@ -12,6 +13,8 @@ import (
1213 "github.com/hashicorp/golang-lru/v2/expirable"
1314 )
1415
16+var ErrMustRevalidate = errors.New("cache is stale and must-revalidate requires revalidation")
17+
1518 type CacheKey interface {
1619 GetCacheKey(r *http.Request) string
1720 }
......@@ -99,19 +102,19 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
99102 // revalidated with conditional headers derived from the stored response.
100103 // Preserve original client conditional headers so we can evaluate them
101104 // after revalidation to decide whether the client gets 304 or 200.
102- clientIfNoneMatch := r.Header.Get("If-None-Match")
103- clientIfModifiedSince := r.Header.Get("If-Modified-Since")
105+ clientIfNoneMatch := r.Header.Get("if-none-match")
106+ clientIfModifiedSince := r.Header.Get("if-modified-since")
104107 clientConditional := clientIfNoneMatch != "" || clientIfModifiedSince != ""
105108
106- if err.Error() == "cache is stale and must-revalidate requires revalidation" {
109+ if errors.Is(err, ErrMustRevalidate) {
107110 if cachedData, exists := c.Cache.Get(cacheKey); exists {
108111 var cachedValue CacheValue
109112 if json.Unmarshal(cachedData, &cachedValue) == nil {
110- if etag := getHeader(cachedValue.Header, "ETag"); etag != "" {
111- r.Header.Set("If-None-Match", etag)
113+ if etag := getHeader(cachedValue.Header, "etag"); etag != "" {
114+ r.Header.Set("if-none-match", etag)
112115 }
113- if lastMod := getHeader(cachedValue.Header, "Last-Modified"); lastMod != "" {
114- r.Header.Set("If-Modified-Since", lastMod)
116+ if lastMod := getHeader(cachedValue.Header, "last-modified"); lastMod != "" {
117+ r.Header.Set("if-modified-since", lastMod)
115118 }
116119 }
117120 }
......@@ -157,21 +160,15 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
157160 if clientConditional {
158161 // Client sent conditional headers -- re-evaluate against the
159162 // updated cached entry and return 304 if it still matches.
160- r.Header.Set("If-None-Match", clientIfNoneMatch)
161- r.Header.Set("If-Modified-Since", clientIfModifiedSince)
162- valid, status := c.handleValidation(r, &cacheValue)
163+ r.Header.Set("if-none-match", clientIfNoneMatch)
164+ r.Header.Set("if-modified-since", clientIfModifiedSince)
165+ valid := c.handleValidation(r, &cacheValue)
163166 if valid {
164- hdr := w.Header()
165- for key, values := range cacheValue.Header {
166- if isForbiddenHeader(key) {
167- continue
168- }
169- hdr[key] = values
170- }
167+ hdr := stripForbiddenHeaders(w, &cacheValue)
171168 ageDur := calcAge(cacheValue.CreatedAt)
172169 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
173- hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
174- w.WriteHeader(status)
170+ hdr.Set("cache-status", cacheStatusStale(cacheKey, wrapped.StatusCode()))
171+ w.WriteHeader(http.StatusNotModified)
175172 return
176173 }
177174 }
......@@ -213,15 +210,7 @@ func isForbiddenHeader(key string) bool {
213210 }
214211
215212 func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string, cacheValue *CacheValue) {
216- hdr := w.Header()
217- for key, values := range cacheValue.Header {
218- // RFC 9111 3.1 - Skip forbidden headers
219- if isForbiddenHeader(key) {
220- continue
221- }
222- hdr[key] = values
223- }
224-
213+ hdr := stripForbiddenHeaders(w, cacheValue)
225214 ageDur := calcAge(cacheValue.CreatedAt)
226215 age := ageDur.Seconds()
227216 hdr.Set("age", strconv.Itoa(int(age)+1))
......@@ -293,30 +282,12 @@ func getHeader(headers map[string][]string, key string) string {
293282 // handleValidation handles conditional request validation.
294283 // RFC 9110 13 Conditional Requests.
295284 // RFC 9111 4.3.2 Response Validation.
296-func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (bool, int) {
297- // Get ETag and Last-Modified with case-insensitive lookup
298- var etag string
299- var lastModified string
300- for key, values := range cacheValue.Header {
301- lowerKey := strings.ToLower(key)
302- c.Logger.Debug(
303- "validate",
304- "key", key,
305- "lowerKey", lowerKey,
306- "values", values,
307- "etag", etag,
308- "lastModified", lastModified,
309- )
310- if lowerKey == "etag" && len(values) > 0 {
311- etag = values[0]
312- }
313- if lowerKey == "last-modified" && len(values) > 0 {
314- lastModified = values[0]
315- }
316- }
285+func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) bool {
286+ etag := getHeader(cacheValue.Header, "etag")
287+ lastModified := getHeader(cacheValue.Header, "last-modified")
317288
318289 c.Logger.Debug(
319- "validate result",
290+ "validate",
320291 "etag", etag,
321292 "lastModified", lastModified,
322293 )
......@@ -327,20 +298,17 @@ func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (b
327298 if ifNoneMatch != "" {
328299 // Wildcard If-None-Match: *
329300 if ifNoneMatch == "*" {
330- if etag != "" {
331- return true, http.StatusNotModified
332- }
333- return false, 0
301+ return etag != ""
334302 }
335303
336304 // Check if any of the provided ETags match
337305 etags := parseETags(ifNoneMatch)
338306 for _, etagVal := range etags {
339307 if etagVal == etag {
340- return true, http.StatusNotModified
308+ return true
341309 }
342310 }
343- return false, 0
311+ return false
344312 }
345313
346314 // RFC 9110 13.1.3 If-Modified-Since
......@@ -352,7 +320,7 @@ func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (b
352320 cachedTime := parseTimeFallback(lastModified)
353321 if !cachedTime.IsZero() {
354322 if !cachedTime.After(reqTime) {
355- return true, http.StatusNotModified
323+ return true
356324 }
357325 }
358326 }
......@@ -371,13 +339,13 @@ func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) (b
371339 // Cached response is not modified since the request time
372340 // We can serve from cache, but don't return 304
373341 // The caller will handle the cache hit
374- return false, 0
342+ return false
375343 }
376344 }
377345 }
378346 }
379347
380- return false, 0
348+ return false
381349 }
382350
383351 func parseETags(etags string) []string {
......@@ -628,7 +596,7 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
628596 age := calcAge(cacheValue.CreatedAt)
629597 freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
630598 if freshness <= 0 {
631- return fmt.Errorf("cache is stale and must-revalidate requires revalidation")
599+ return ErrMustRevalidate
632600 }
633601 }
634602
......@@ -640,32 +608,22 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
640608 return fmt.Errorf("cache has no-store")
641609 }
642610
611+ age := calcAge(cacheValue.CreatedAt)
612+ freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
613+
643614 // RFC 9111 4.3 Validation - check validation headers first
644615 // RFC 9110 13 Conditional Requests
645616 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13
646- valid, status := c.handleValidation(r, &cacheValue)
617+ valid := c.handleValidation(r, &cacheValue)
647618 if valid {
648- // RFC 9111 4.3.4 304 Not Modified
649- // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
650- // A 304 response must include headers the client needs to update
651- // its cached representation (ETag, Last-Modified, Cache-Control, etc.)
652- hdr := w.Header()
653- for key, values := range cacheValue.Header {
654- if isForbiddenHeader(key) {
655- continue
656- }
657- hdr[key] = values
658- }
619+ hdr := stripForbiddenHeaders(w, &cacheValue)
659620 ageDur := calcAge(cacheValue.CreatedAt)
660621 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
661- hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
662- w.WriteHeader(status)
622+ hdr.Set("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
623+ w.WriteHeader(http.StatusNotModified)
663624 return nil
664625 }
665626
666- age := calcAge(cacheValue.CreatedAt)
667- freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
668-
669627 // Check if request allows stale responses (max-stale)
670628 // RFC 9111 5.2.1.2 - max-stale allows serving stale responses
671629 // We need to check this before the freshness <= 0 check
......@@ -758,6 +716,10 @@ func cacheStatusHit(cacheKey string, ttl float64) string {
758716 return fmt.Sprintf("pico; hit; ttl=%d; key=%s", int(ttl), cacheKey)
759717 }
760718
719+func cacheStatusStale(cacheKey string, originStatus int) string {
720+ return fmt.Sprintf("pico; fwd=stale; fwd-status=%d", originStatus)
721+}
722+
761723 func cacheStatusMiss(cacheKey string, stored bool) string {
762724 // RFC 9211 2.2 Cache-Status fwd
763725 // https://www.rfc-editor.org/rfc/rfc9211#section-2.2
......@@ -772,3 +734,14 @@ func cacheStatusMiss(cacheKey string, stored bool) string {
772734 status = fmt.Sprintf("%s; key=%s", status, cacheKey)
773735 return status
774736 }
737+
738+func stripForbiddenHeaders(w http.ResponseWriter, cacheValue *CacheValue) http.Header {
739+ hdr := w.Header()
740+ for key, values := range cacheValue.Header {
741+ if isForbiddenHeader(key) {
742+ continue
743+ }
744+ hdr[key] = values
745+ }
746+ return hdr
747+}