Skip to content

Commit 56a31b6

Browse files
authored
fix: handle multiple Vary header lines (#33)
* Include all Vary header lines when building the normalized vary key. * Add regression test for multiple Vary header lines. Closes #32
1 parent 1b1dd0f commit 56a31b6

File tree

3 files changed

+101
-3
lines changed

3 files changed

+101
-3
lines changed

internal/responsestorerer.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"maps"
1919
"net/http"
2020
"slices"
21+
"strings"
2122
"time"
2223
)
2324

@@ -59,7 +60,8 @@ func (r *responseStorer) StoreResponse(
5960
// Remove hop-by-hop headers as per RFC 9111 §3.1
6061
removeHopByHopHeaders(resp)
6162

62-
vary := resp.Header.Get("Vary")
63+
// Join multiple Vary headers into a single string
64+
vary := strings.Join(resp.Header.Values("Vary"), ",")
6365
varyResolved := maps.Collect(
6466
r.vhn.NormalizeVaryHeader(vary, req.Header),
6567
)

roundtripper_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package httpcache
1616

1717
import (
1818
"errors"
19+
"fmt"
1920
"io"
2021
"log/slog"
2122
"net/http"
@@ -957,3 +958,90 @@ func Test_transport_RevalidationUpdatesCache(t *testing.T) {
957958
})
958959
}
959960
}
961+
962+
// Test_transport_Vary_MultipleHeaders verifies that the transport correctly
963+
// handles multiple Vary headers that are sent as separate header lines
964+
// (instead of a single comma-separated line). This is a common scenario in
965+
// practice, and there was a bug where only the first Vary header line was
966+
// being processed, causing incorrect cache hits when subsequent Vary headers
967+
// were not considered.
968+
// Regression test for: https://github.com/bartventer/httpcache/issues/32
969+
func Test_transport_Vary_MultipleHeaders(t *testing.T) {
970+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
971+
// Simulate server returning multiple separate Vary header lines
972+
// (not comma-separated, but as distinct headers)
973+
w.Header().Add("Vary", "Accept-Language")
974+
w.Header().Add("Vary", "X-Compatibility-Date")
975+
w.Header().Set("Cache-Control", "max-age=60")
976+
w.WriteHeader(http.StatusOK)
977+
978+
lang := r.Header.Get("Accept-Language")
979+
date := r.Header.Get("X-Compatibility-Date")
980+
_, _ = fmt.Fprintf(w, "lang=%s date=%s", lang, date)
981+
}))
982+
defer server.Close()
983+
984+
c := memcache.Open()
985+
tr := newTransport(c, WithLogger(slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
986+
Level: slog.LevelDebug,
987+
}))))
988+
989+
makeReq := func(lang, date string) *http.Response {
990+
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
991+
req.Header.Set("Accept-Language", lang)
992+
req.Header.Set("X-Compatibility-Date", date)
993+
resp, err := tr.RoundTrip(req)
994+
testutil.RequireNoError(t, err)
995+
return resp
996+
}
997+
998+
// Request 1: MISS — first request for en-us + 2025-01-01
999+
resp := makeReq("en-us", "2025-01-01")
1000+
testutil.AssertEqual(t, http.StatusOK, resp.StatusCode)
1001+
testutil.AssertEqual(
1002+
t,
1003+
internal.CacheStatusMiss.Value,
1004+
resp.Header.Get(internal.CacheStatusHeader),
1005+
)
1006+
body, _ := io.ReadAll(resp.Body)
1007+
_ = resp.Body.Close()
1008+
testutil.AssertEqual(t, "lang=en-us date=2025-01-01", string(body))
1009+
1010+
// Request 2: HIT — same headers
1011+
resp = makeReq("en-us", "2025-01-01")
1012+
testutil.AssertEqual(t, http.StatusOK, resp.StatusCode)
1013+
testutil.AssertEqual(
1014+
t,
1015+
internal.CacheStatusHit.Value,
1016+
resp.Header.Get(internal.CacheStatusHeader),
1017+
)
1018+
body, _ = io.ReadAll(resp.Body)
1019+
_ = resp.Body.Close()
1020+
testutil.AssertEqual(t, "lang=en-us date=2025-01-01", string(body))
1021+
1022+
// Request 3: MISS — X-Compatibility-Date changed, must be a different cache entry
1023+
// This is the bug: without the fix, this incorrectly returns a HIT
1024+
resp = makeReq("en-us", "2026-01-01")
1025+
testutil.AssertEqual(t, http.StatusOK, resp.StatusCode)
1026+
testutil.AssertEqual(
1027+
t,
1028+
internal.CacheStatusMiss.Value,
1029+
resp.Header.Get(internal.CacheStatusHeader),
1030+
"X-Compatibility-Date is a Vary header; different value must produce a cache MISS",
1031+
)
1032+
body, _ = io.ReadAll(resp.Body)
1033+
_ = resp.Body.Close()
1034+
testutil.AssertEqual(t, "lang=en-us date=2026-01-01", string(body))
1035+
1036+
// Request 4: HIT — same as request 3
1037+
resp = makeReq("en-us", "2026-01-01")
1038+
testutil.AssertEqual(t, http.StatusOK, resp.StatusCode)
1039+
testutil.AssertEqual(
1040+
t,
1041+
internal.CacheStatusHit.Value,
1042+
resp.Header.Get(internal.CacheStatusHeader),
1043+
)
1044+
body, _ = io.ReadAll(resp.Body)
1045+
_ = resp.Body.Close()
1046+
testutil.AssertEqual(t, "lang=en-us date=2026-01-01", string(body))
1047+
}

store/expapi/expapi.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,11 @@ func retrieve(conn driver.Conn) http.Handler {
9898
if errors.Is(err, driver.ErrNotExist) {
9999
http.Error(w, fmt.Sprintf("key %q not found", key), http.StatusNotFound)
100100
} else {
101-
http.Error(w, fmt.Sprintf("failed to get value for key %q: %v", key, err), http.StatusInternalServerError)
101+
http.Error(
102+
w,
103+
fmt.Sprintf("failed to get value for key %q: %v", key, err),
104+
http.StatusInternalServerError,
105+
)
102106
}
103107
return
104108
}
@@ -121,7 +125,11 @@ func destroy(conn driver.Conn) http.Handler {
121125
if errors.Is(err, driver.ErrNotExist) {
122126
http.Error(w, fmt.Sprintf("key %q not found", key), http.StatusNotFound)
123127
} else {
124-
http.Error(w, fmt.Sprintf("failed to delete value for key %q: %v", key, err), http.StatusInternalServerError)
128+
http.Error(
129+
w,
130+
fmt.Sprintf("failed to delete value for key %q: %v", key, err),
131+
http.StatusInternalServerError,
132+
)
125133
}
126134
return
127135
}

0 commit comments

Comments
 (0)