Commit ee0c5e7

Eric Bower  ·  2026-08-13 10:17:32 -0400 EDT
parent 538c3e9
fix(httpscache): flaky test
2 files changed,  +26, -14
+17, -13
 1@@ -729,19 +729,22 @@ func TestCacheExpires(t *testing.T) {
 2 // RFC 9111 4.3.4 304 Not Modified
 3 // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
 4 // When a cached entry is validated and the origin responds with 304, the cache:
 5-// - Returns 304 to the client
 6 // - Updates header metadata from the 304 response
 7 // - Retains the cached body for subsequent requests.
 8+// If the client's conditional headers no longer match the updated cache entry,
 9+// the cache serves 200 with the full cached body.
10 func TestCache304NotModifiedMerge(t *testing.T) {
11 	originCalls := 0
12 
13-	// Validation handler: returns 304 when ETag matches, 200 otherwise
14+	// Validation handler: returns 304 when ETag matches, 200 otherwise.
15+	// The 304 response includes an updated ETag header (set before WriteHeader).
16 	validationMux := http.NewServeMux()
17 	validationMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
18 		originCalls++
19 		if r.Header.Get("If-None-Match") == "\"abc\"" {
20-			w.WriteHeader(http.StatusNotModified)
21+			// Set headers before WriteHeader (correct HTTP practice).
22 			w.Header().Set("etag", "\"abc-updated\"")
23+			w.WriteHeader(http.StatusNotModified)
24 			return
25 		}
26 		w.Header().Set("etag", "\"abc\"")
27@@ -766,22 +769,23 @@ func TestCache304NotModifiedMerge(t *testing.T) {
28 	cacheData, _ := json.Marshal(staleCv)
29 	handler.Cache.Add(cacheKey, cacheData)
30 
31-	// First request with If-None-Match triggers validation; origin returns 304.
32-	// Client sent conditional headers, so if they still match the updated cache
33-	// entry, the client gets 304. Here the upstream updated the ETag to "abc-updated"
34-	// so the client's If-None-Match "abc" no longer matches — serve cached body as 200.
35+	// First request with If-None-Match triggers validation; origin returns 304
36+	// with an updated ETag "abc-updated".
37+	// The cache merges the new ETag into the stored entry, then re-evaluates
38+	// the client's If-None-Match "abc" against the updated ETag "abc-updated".
39+	// They no longer match, so the cache serves the full cached body as 200.
40 	resp1, _ := tc.DoWithHeaders(req, map[string][]string{
41 		"If-None-Match": {"\"abc\""},
42 	})
43-	if resp1.StatusCode != http.StatusNotModified {
44-		t.Errorf("expected 304 (ETag changed after revalidation), got %d", resp1.StatusCode)
45+	if resp1.StatusCode != http.StatusOK {
46+		t.Errorf("expected 200 (updated ETag no longer matches client If-None-Match), got %d", resp1.StatusCode)
47 	}
48 	status := resp1.Header.Get("cache-status")
49-	if !strings.Contains(status, "fwd=stale") {
50-		t.Errorf("expected cache-status hit, got %s", status)
51+	if !strings.Contains(status, "hit") {
52+		t.Errorf("expected cache-status to contain 'hit', got %s", status)
53 	}
54 
55-	// Second request without conditional headers should still serve the cached body
56+	// Second request without conditional headers should still serve the cached body.
57 	resp2, _ := tc.Do(req)
58 	if resp2.StatusCode != http.StatusOK {
59 		t.Errorf("expected 200, got %d", resp2.StatusCode)
60@@ -797,7 +801,7 @@ func TestCache304NotModifiedMerge(t *testing.T) {
61 		t.Errorf("expected cache-status hit on second request, got %s", status2)
62 	}
63 
64-	// Origin should have been called exactly once (304 validation only)
65+	// Origin should have been called exactly once (304 validation only).
66 	if originCalls != 1 {
67 		t.Errorf("expected 1 origin call, got %d", originCalls)
68 	}
+9, -1
 1@@ -145,11 +145,19 @@ func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 2 
 3 		// Merge non-forbidden headers from the 304 response into the cached entry.
 4 		// Normalize keys to lowercase to avoid case-sensitivity issues.
 5+		// Delete any existing case-insensitive duplicates first so that getHeader
 6+		// cannot find both the old and new values on random map iteration.
 7 		for key, values := range wrapped.Header() {
 8 			if isForbiddenHeader(key) {
 9 				continue
10 			}
11-			cacheValue.Header[strings.ToLower(key)] = values
12+			normKey := strings.ToLower(key)
13+			for existing := range cacheValue.Header {
14+				if strings.EqualFold(existing, normKey) {
15+					delete(cacheValue.Header, existing)
16+				}
17+			}
18+			cacheValue.Header[normKey] = values
19 		}
20 		// Revalidation refreshes the entry -- reset CreatedAt so it's fresh again.
21 		cacheValue.CreatedAt = time.Now()