repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

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
M pkg/httpcache/cache_test.go
+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 
M pkg/httpcache/serve.go
+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+}