Skip to content

Commit 7463c1a

Browse files
committed
fix: cache responses without public directive
1 parent 37367c4 commit 7463c1a

3 files changed

Lines changed: 44 additions & 24 deletions

File tree

v2/pkg/caching/cachecontrol.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@ func TTL(headers http.Header, defaultTTL time.Duration) (time.Duration, bool) {
1717
return 0, false
1818
}
1919

20-
// We for now consider no cache and private as dont cache even
20+
// We currently treat no-cache and private as not reusable by this shared cache.
2121
if cc.NoCache != nil || cc.Private != nil {
2222
return 0, false
2323
}
24-
if !cc.Public {
25-
return 0, false
26-
}
2724

25+
// An explicit freshness lifetime makes a response cacheable without the
26+
// public directive. Public is only needed below when the response relies on
27+
// the configured fallback TTL.
2828
switch {
2929
case cc.SMaxAge != nil:
3030
if *cc.SMaxAge <= 0 {
@@ -39,6 +39,10 @@ func TTL(headers http.Header, defaultTTL time.Duration) (time.Duration, bool) {
3939
return cc.MaxAge.AsDuration(), true
4040
}
4141

42+
if !cc.Public {
43+
return 0, false
44+
}
45+
4246
if defaultTTL <= 0 {
4347
return 0, false
4448
}

v2/pkg/caching/cachecontrol_test.go

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,47 @@ func TestTTL(t *testing.T) {
1616
wantTTL time.Duration
1717
wantOK bool
1818
}{
19-
// public is the opt-in, and on its own it takes the configured default.
20-
{name: "public alone", cacheControl: "public", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
19+
// A positive freshness lifetime is sufficient to store a response. Public
20+
// permits storage in cases that would otherwise forbid it; it is not a
21+
// general prerequisite for shared caching.
22+
{name: "max-age without public", cacheControl: "max-age=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
23+
{name: "s-maxage without public", cacheControl: "s-maxage=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
2124
{name: "public with max-age", cacheControl: "public, max-age=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
2225
{name: "public with s-maxage", cacheControl: "public, s-maxage=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
26+
{name: "max-age does not need a usable default", cacheControl: "max-age=60", wantTTL: time.Minute, wantOK: true},
2327

2428
// s-maxage is the directive addressed to shared caches, so it outranks
2529
// max-age here however the two are ordered in the header.
26-
{name: "s-maxage outranks max-age", cacheControl: "public, s-maxage=60, max-age=3600", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
27-
{name: "s-maxage outranks max-age reversed", cacheControl: "public, max-age=3600, s-maxage=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
30+
{name: "s-maxage outranks max-age", cacheControl: "s-maxage=60, max-age=3600", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
31+
{name: "s-maxage outranks max-age reversed", cacheControl: "max-age=3600, s-maxage=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
32+
{name: "zero s-maxage overrides positive max-age", cacheControl: "max-age=60, s-maxage=0", defaultTTL: defaultTTL, wantOK: false},
33+
{name: "positive s-maxage overrides zero max-age", cacheControl: "max-age=0, s-maxage=60", defaultTTL: defaultTTL, wantTTL: time.Minute, wantOK: true},
34+
35+
// Public without a freshness lifetime opts into the configured fallback.
36+
// No header or a header without storage intent does not.
37+
{name: "public alone", cacheControl: "public", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
38+
{name: "public with another directive", cacheControl: "public, must-revalidate", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
39+
{name: "no cache-control header", defaultTTL: defaultTTL, wantOK: false},
40+
{name: "unrelated directive without public", cacheControl: "must-revalidate", defaultTTL: defaultTTL, wantOK: false},
41+
{name: "extension directive without public", cacheControl: "cdn-cache-control=60", defaultTTL: defaultTTL, wantOK: false},
2842

29-
// Without public the response is not offered to a shared cache, whatever
30-
// lifetime it names.
31-
{name: "max-age without public", cacheControl: "max-age=60", defaultTTL: defaultTTL, wantOK: false},
32-
{name: "s-maxage without public", cacheControl: "s-maxage=60", defaultTTL: defaultTTL, wantOK: false},
33-
{name: "no cache-control header", cacheControl: "", defaultTTL: defaultTTL, wantOK: false},
43+
// Explicit refusals win whether public is present or not.
44+
{name: "no-store", cacheControl: "max-age=60, no-store", defaultTTL: defaultTTL, wantOK: false},
45+
{name: "no-cache", cacheControl: "max-age=60, no-cache", defaultTTL: defaultTTL, wantOK: false},
46+
{name: "field-specific no-cache", cacheControl: `max-age=60, no-cache="Set-Cookie"`, defaultTTL: defaultTTL, wantOK: false},
47+
{name: "private", cacheControl: "max-age=60, private", defaultTTL: defaultTTL, wantOK: false},
48+
{name: "private wins over public", cacheControl: "public, max-age=60, private", defaultTTL: defaultTTL, wantOK: false},
3449

35-
// Explicit refusals win over public.
36-
{name: "no-store", cacheControl: "public, no-store", defaultTTL: defaultTTL, wantOK: false},
37-
{name: "no-cache", cacheControl: "public, no-cache", defaultTTL: defaultTTL, wantOK: false},
38-
{name: "private", cacheControl: "public, private", defaultTTL: defaultTTL, wantOK: false},
50+
// A lifetime of zero is a refusal, and malformed lifetimes cannot opt a
51+
// response into caching.
52+
{name: "zero max-age", cacheControl: "max-age=0", defaultTTL: defaultTTL, wantOK: false},
53+
{name: "zero s-maxage", cacheControl: "s-maxage=0", defaultTTL: defaultTTL, wantOK: false},
54+
{name: "public does not override zero max-age", cacheControl: "public, max-age=0", defaultTTL: defaultTTL, wantOK: false},
55+
{name: "invalid max-age", cacheControl: "max-age=abc", defaultTTL: defaultTTL, wantOK: false},
3956

40-
// A lifetime of zero is a refusal, and so is a default that was never
41-
// usable in the first place.
42-
{name: "zero max-age", cacheControl: "public, max-age=0", defaultTTL: defaultTTL, wantOK: false},
43-
{name: "zero s-maxage", cacheControl: "public, s-maxage=0", defaultTTL: defaultTTL, wantOK: false},
44-
{name: "public with non-positive default", cacheControl: "public", defaultTTL: 0, wantOK: false},
57+
// The fallback must itself be usable.
58+
{name: "public with zero default", cacheControl: "public", wantOK: false},
59+
{name: "public with negative default", cacheControl: "public", defaultTTL: -time.Second, wantOK: false},
4560
} {
4661
t.Run(tc.name, func(t *testing.T) {
4762
headers := http.Header{}

v2/pkg/engine/resolve/response_cache_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,8 @@ func TestResponseCacheCollectTags(t *testing.T) {
406406
statusCode: http.StatusOK,
407407
httpResponseContext: &httpclient.ResponseContext{
408408
Response: &http.Response{
409-
Header: http.Header{"Cache-Control": []string{"public, max-age=60"}},
409+
// An explicit freshness lifetime is cacheable without public.
410+
Header: http.Header{"Cache-Control": []string{"max-age=60"}},
410411
},
411412
},
412413
}
@@ -500,7 +501,7 @@ func TestResponseCacheCollectTags(t *testing.T) {
500501
require.Equal(t, []string{"declared:accounts:user-7"}, items[1].Tags)
501502
})
502503

503-
t.Run("a response with no tags is cached exactly as it was before", func(t *testing.T) {
504+
t.Run("max-age without public is cached when the response has no tags", func(t *testing.T) {
504505
body := `{"data": {"_entities": [{"id": 42}]}}`
505506
items := collect(t, body, []string{"k-42"}, declaredOnly)
506507

0 commit comments

Comments
 (0)