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()