repos / pico

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

commit
d758032
parent
272d185
author
Eric Bower
date
2026-04-19 11:32:48 -0400 EDT
fix(pgs): 304 send correct status and headers
2 files changed,  +78, -25
M pkg/httpcache/cache_test.go
+16, -10
 1@@ -765,12 +765,15 @@ func TestCache304NotModifiedMerge(t *testing.T) {
 2 	cacheData, _ := json.Marshal(staleCv)
 3 	handler.Cache.Add(cacheKey, cacheData)
 4 
 5-	// First request with If-None-Match triggers validation; origin returns 304
 6+	// First request with If-None-Match triggers validation; origin returns 304.
 7+	// Client sent conditional headers, so if they still match the updated cache
 8+	// entry, the client gets 304. Here the upstream updated the ETag to "abc-updated"
 9+	// so the client's If-None-Match "abc" no longer matches — serve cached body as 200.
10 	resp1, _ := tc.DoWithHeaders(req, map[string][]string{
11 		"If-None-Match": {"\"abc\""},
12 	})
13-	if resp1.StatusCode != http.StatusNotModified {
14-		t.Errorf("expected 304, got %d", resp1.StatusCode)
15+	if resp1.StatusCode != http.StatusOK {
16+		t.Errorf("expected 200 (ETag changed after revalidation), got %d", resp1.StatusCode)
17 	}
18 	status := resp1.Header.Get("cache-status")
19 	if !strings.Contains(status, "hit") {
20@@ -889,14 +892,15 @@ func TestCache304NoBody(t *testing.T) {
21 	cacheData, _ := json.Marshal(cv)
22 	handler.Cache.Add(cacheKey, cacheData)
23 
24-	// Trigger revalidation — upstream returns 304 with a spurious body
25+	// Trigger revalidation — upstream returns 304 with a spurious body.
26+	// Client request is unconditional, so cache serves the stored body as 200.
27 	resp, _ := tc.Do(req)
28-	if resp.StatusCode != http.StatusNotModified {
29-		t.Fatalf("expected 304, got %d", resp.StatusCode)
30+	if resp.StatusCode != http.StatusOK {
31+		t.Fatalf("expected 200, got %d", resp.StatusCode)
32 	}
33 	body, _ := readBody(resp)
34-	if body != "" {
35-		t.Errorf("expected empty body for 304 response, got %q", body)
36+	if body != "original body" {
37+		t.Errorf("expected cached body 'original body', got %q", body)
38 	}
39 }
40 
41@@ -1075,8 +1079,10 @@ func TestCacheMustRevalidateRevalidationHeaders(t *testing.T) {
42 
43 			resp, _ := tc.Do(req)
44 
45-			if resp.StatusCode != http.StatusNotModified {
46-				t.Errorf("expected 304, got %d", resp.StatusCode)
47+			// Client request is unconditional — after upstream 304, cache
48+			// serves the stored body as 200.
49+			if resp.StatusCode != http.StatusOK {
50+				t.Errorf("expected 200, got %d", resp.StatusCode)
51 			}
52 			status := resp.Header.Get("cache-status")
53 			if !strings.Contains(status, "hit") {
M pkg/httpcache/serve.go
+62, -15
  1@@ -90,6 +90,12 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
  2 
  3 	// RFC 9111 4.2.4 + 4.3.1/4.3.2: stale must-revalidate entries must be
  4 	// revalidated with conditional headers derived from the stored response.
  5+	// Preserve original client conditional headers so we can evaluate them
  6+	// after revalidation to decide whether the client gets 304 or 200.
  7+	clientIfNoneMatch := r.Header.Get("If-None-Match")
  8+	clientIfModifiedSince := r.Header.Get("If-Modified-Since")
  9+	clientConditional := clientIfNoneMatch != "" || clientIfModifiedSince != ""
 10+
 11 	if err.Error() == "cache is stale and must-revalidate requires revalidation" {
 12 		if cachedData, exists := c.Cache.Get(cacheKey); exists {
 13 			var cachedValue CacheValue
 14@@ -114,22 +120,58 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 15 	if wrapped.StatusCode() == http.StatusNotModified {
 16 		log.Info("304 not modified, updating cached headers")
 17 		existingData, exists := c.Cache.Get(cacheKey)
 18-		if exists {
 19-			var cacheValue CacheValue
 20-			if json.Unmarshal(existingData, &cacheValue) == nil {
 21-				// Merge headers from the 304 response into the cached entry.
 22-				for key, values := range wrapped.Header() {
 23-					cacheValue.Header[key] = values
 24+		if !exists {
 25+			// Cache entry vanished; forward the 304 as-is.
 26+			wrapped.Send()
 27+			return
 28+		}
 29+
 30+		var cacheValue CacheValue
 31+		if json.Unmarshal(existingData, &cacheValue) != nil {
 32+			wrapped.Send()
 33+			return
 34+		}
 35+
 36+		// Merge non-forbidden headers from the 304 response into the cached entry.
 37+		for key, values := range wrapped.Header() {
 38+			if isForbiddenHeader(key) {
 39+				continue
 40+			}
 41+			cacheValue.Header[key] = values
 42+		}
 43+		// Revalidation refreshes the entry — reset CreatedAt so it's fresh again.
 44+		cacheValue.CreatedAt = time.Now()
 45+		enc, _ := json.Marshal(cacheValue)
 46+		c.Cache.Add(cacheKey, enc)
 47+		c.AddCacheItem(float64(len(enc)))
 48+
 49+		if clientConditional {
 50+			// Client sent conditional headers — re-evaluate against the
 51+			// updated cached entry and return 304 if it still matches.
 52+			r.Header.Set("If-None-Match", clientIfNoneMatch)
 53+			r.Header.Set("If-Modified-Since", clientIfModifiedSince)
 54+			valid, status := 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+					for _, value := range values {
 62+						hdr.Add(key, value)
 63+					}
 64 				}
 65-				// Revalidation refreshes the entry — reset CreatedAt so it's fresh again.
 66-				cacheValue.CreatedAt = time.Now()
 67-				enc, _ := json.Marshal(cacheValue)
 68-				c.Cache.Add(cacheKey, enc)
 69-				c.AddCacheItem(float64(len(enc)))
 70+				ageDur := calcAge(cacheValue.CreatedAt)
 71+				hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
 72+				hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
 73+				w.WriteHeader(status)
 74+				return
 75 			}
 76 		}
 77-		wrapped.Header().Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
 78-		wrapped.Send()
 79+
 80+		// Client request was unconditional (or conditional but no longer matches) —
 81+		// serve the full cached response.
 82+		serveCache(w, c.Ttl, cacheKey, &cacheValue)
 83 		return
 84 	}
 85 
 86@@ -154,8 +196,8 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 87 func isForbiddenHeader(key string) bool {
 88 	switch strings.ToLower(key) {
 89 	case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
 90-		"teardown", "transfer-encoding", "upgrade", "proxy-connection",
 91-		"www-authenticate", "proxy-authentication-info":
 92+		"te", "trailer", "transfer-encoding", "upgrade", "proxy-connection",
 93+		"proxy-authentication-info":
 94 		return true
 95 	default:
 96 		return false
 97@@ -178,6 +220,9 @@ func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string,
 98 	age := ageDur.Seconds()
 99 	hdr.Add("age", strconv.Itoa(int(age)+1))
100 	hdr.Add("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
101+	if cacheValue.StatusCode != 0 && cacheValue.StatusCode != http.StatusOK {
102+		w.WriteHeader(cacheValue.StatusCode)
103+	}
104 	_, _ = w.Write(cacheValue.Body)
105 }
106 
107@@ -605,6 +650,8 @@ func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *htt
108 				hdr.Add(key, value)
109 			}
110 		}
111+		ageDur := calcAge(cacheValue.CreatedAt)
112+		hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
113 		hdr.Set("cache-status", cacheStatusHit(cacheKey, c.Ttl.Seconds()))
114 		w.WriteHeader(status)
115 		return nil