Skip to content

Commit 3cfaeec

Browse files
cih9088bartventer
andauthored
fix: refresh stored headers on 304 revalidation (#27)
* Refresh stored cache headers from 304 validation responses (RFC 9111 §4.3.4). * Add regression/integration coverage for stale -> 304 revalidate -> fresh HIT flow. * Rename `updateStoredHeaders` to `mergeResponseHeaders` for clarity. --------- Co-authored-by: Bart Venter <72999113+bartventer@users.noreply.github.com>
1 parent 60e8c9f commit 3cfaeec

5 files changed

Lines changed: 126 additions & 12 deletions

File tree

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ $(FMTSTAMP): $(GOFILES) $(GOTESTFILES)
5858
lint: $(LINTSTAMP) ## Run linters
5959

6060
$(LINTSTAMP): $(GOFILES) $(GOTESTFILES)
61-
golangci-lint run --verbose
61+
golangci-lint run --disable goheader --verbose
6262
touch $@
6363

6464
## Testing:

internal/helpers.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -88,17 +88,17 @@ func removeHopByHopHeaders(resp *http.Response) {
8888
}
8989
}
9090

91-
// updateStoredHeaders updates the stored response headers with the
92-
// headers from the revalidated response, excluding hop-by-hop headers
93-
// and the Content-Length header, as per RFC 9111 §3.2.
94-
func updateStoredHeaders(storedResp, resp *http.Response) {
95-
omitted := hopByHopHeaders(resp.Header)
96-
omitted["Content-Length"] = struct{}{}
97-
for hdr, val := range resp.Header {
98-
if _, ok := omitted[hdr]; ok {
91+
// mergeResponseHeaders merges the headers from the revalidated response into
92+
// the stored response, excluding hop-by-hop headers and the Content-Length
93+
// header, as per RFC 9111 §3.2.
94+
func mergeResponseHeaders(targetResp *http.Response, srcHdrs http.Header) {
95+
nonCacheHdrs := hopByHopHeaders(srcHdrs)
96+
nonCacheHdrs["Content-Length"] = struct{}{}
97+
for hdr, val := range srcHdrs {
98+
if _, ok := nonCacheHdrs[hdr]; ok {
9999
continue
100100
}
101-
storedResp.Header[hdr] = val
101+
targetResp.Header[hdr] = val
102102
}
103103
}
104104

internal/validationresponsehandler.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,16 @@ func (r *validationResponseHandler) HandleValidationResponse(
8080
if err == nil && req.Method == http.MethodGet && resp.StatusCode == http.StatusNotModified {
8181
// RFC 9111 §4.3.3 Handling Validation Responses (304 Not Modified)
8282
// RFC 9111 §4.3.4 Freshening Stored Responses upon Validation
83-
updateStoredHeaders(ctx.Stored.Data, resp)
83+
mergeResponseHeaders(ctx.Stored.Data, resp.Header)
84+
_ = r.rs.StoreResponse(
85+
req,
86+
ctx.Stored.Data,
87+
ctx.URLKey,
88+
ctx.Refs,
89+
ctx.Start,
90+
ctx.End,
91+
ctx.RefIndex,
92+
)
8493
CacheStatusRevalidated.ApplyTo(ctx.Stored.Data.Header)
8594
r.l.LogCacheRevalidated(req, ctx.URLKey, ctx.ToMisc(nil))
8695
return ctx.Stored.Data, nil

internal/validationresponsehandler_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@ func Test_validationResponseHandler_HandleValidationResponse(t *testing.T) {
6565
l: noopLogger,
6666
},
6767
setup: func(tt *testing.T, handler *validationResponseHandler) args {
68+
handler.rs = &MockResponseStorer{
69+
StoreResponseFunc: func(req *http.Request, resp *http.Response, key string, headers ResponseRefs, reqTime, respTime time.Time, refIndex int) error {
70+
testutil.AssertEqual(tt, "key", key)
71+
testutil.AssertTrue(tt, respTime.Equal(base))
72+
testutil.AssertTrue(tt, reqTime.Equal(base))
73+
return nil
74+
},
75+
}
6876
return args{
6977
req: &http.Request{Method: http.MethodGet},
7078
resp: &http.Response{StatusCode: http.StatusNotModified, Header: http.Header{}},

roundtripper_test.go

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,14 @@ import (
2222
"net/http/httptest"
2323
"net/url"
2424
"os"
25+
"sync/atomic"
2526
"testing"
2627
"time"
2728

2829
"github.com/bartventer/httpcache/internal"
2930
"github.com/bartventer/httpcache/internal/testutil"
3031
"github.com/bartventer/httpcache/store"
31-
_ "github.com/bartventer/httpcache/store/memcache"
32+
"github.com/bartventer/httpcache/store/memcache"
3233
)
3334

3435
func mockTransport(fields func(rt *transport)) *transport {
@@ -860,3 +861,99 @@ func Test_transport_Vary(t *testing.T) {
860861
testutil.AssertEqual(t, tc.wantBody, string(body), i)
861862
}
862863
}
864+
865+
// This test verifies that when a cached response is revalidated via a 304 Not
866+
// Modified, the cache entry is updated with any new headers from the 304
867+
// response, and subsequent requests can HIT the cache again until it becomes
868+
// stale once more.
869+
func Test_transport_RevalidationUpdatesCache(t *testing.T) {
870+
var originCalls atomic.Int32
871+
872+
const etag = `"v1"`
873+
874+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
875+
originCalls.Add(1)
876+
877+
// Revalidation path: client sends validator, server says cached body is still valid
878+
if r.Header.Get("If-None-Match") == etag {
879+
w.Header().Set("ETag", etag)
880+
w.Header().Set("Cache-Control", "max-age=1")
881+
w.Header().Set("Expires", time.Now().Add(1*time.Second).UTC().Format(http.TimeFormat))
882+
w.WriteHeader(http.StatusNotModified)
883+
return
884+
}
885+
886+
// Initial fetch
887+
w.Header().Set("ETag", etag)
888+
w.Header().Set("Cache-Control", "max-age=1")
889+
w.Header().Set("Expires", time.Now().Add(1*time.Second).UTC().Format(http.TimeFormat))
890+
w.WriteHeader(http.StatusOK)
891+
_, _ = w.Write([]byte("hello"))
892+
}))
893+
defer server.Close()
894+
895+
c := memcache.Open()
896+
tr := newTransport(c)
897+
898+
req, _ := http.NewRequest(http.MethodGet, server.URL, nil)
899+
900+
tests := []struct {
901+
name string
902+
expectedStatusCode int
903+
expectedCacheStatus string
904+
expectedBody string
905+
expectedOriginCalls int32
906+
preReqFunc func()
907+
}{
908+
{
909+
name: "Initial request should be a MISS",
910+
expectedStatusCode: http.StatusOK,
911+
expectedCacheStatus: internal.CacheStatusMiss.Value,
912+
expectedBody: "hello",
913+
expectedOriginCalls: 1,
914+
},
915+
{
916+
name: "Second request should be a HIT",
917+
expectedStatusCode: http.StatusOK,
918+
expectedCacheStatus: internal.CacheStatusHit.Value,
919+
expectedBody: "hello",
920+
expectedOriginCalls: 1,
921+
},
922+
{
923+
name: "After becoming stale, request should be REVALIDATED via 304",
924+
expectedStatusCode: http.StatusOK,
925+
expectedCacheStatus: internal.CacheStatusRevalidated.Value,
926+
expectedBody: "hello",
927+
expectedOriginCalls: 2,
928+
preReqFunc: func() {
929+
time.Sleep(1100 * time.Millisecond)
930+
},
931+
},
932+
{
933+
name: "After revalidation, request should be HIT again",
934+
expectedStatusCode: http.StatusOK,
935+
expectedCacheStatus: internal.CacheStatusHit.Value,
936+
expectedBody: "hello",
937+
expectedOriginCalls: 2,
938+
},
939+
}
940+
for _, tc := range tests {
941+
t.Run(tc.name, func(t *testing.T) {
942+
if tc.preReqFunc != nil {
943+
tc.preReqFunc()
944+
}
945+
resp, err := tr.RoundTrip(req)
946+
testutil.RequireNoError(t, err)
947+
testutil.AssertEqual(t, tc.expectedStatusCode, resp.StatusCode)
948+
testutil.AssertEqual(
949+
t,
950+
tc.expectedCacheStatus,
951+
resp.Header.Get(internal.CacheStatusHeader),
952+
)
953+
body, _ := io.ReadAll(resp.Body)
954+
_ = resp.Body.Close()
955+
testutil.AssertEqual(t, tc.expectedBody, string(body))
956+
testutil.AssertEqual(t, tc.expectedOriginCalls, originCalls.Load())
957+
})
958+
}
959+
}

0 commit comments

Comments
 (0)