Skip to content

Commit 1ec78f3

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

5 files changed

Lines changed: 112 additions & 46 deletions

File tree

v2/pkg/caching/cachecontrol.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,13 @@ 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 takes precedence over the configured
26+
// fallback TTL.
2827
switch {
2928
case cc.SMaxAge != nil:
3029
if *cc.SMaxAge <= 0 {
@@ -39,6 +38,13 @@ func TTL(headers http.Header, defaultTTL time.Duration) (time.Duration, bool) {
3938
return cc.MaxAge.AsDuration(), true
4039
}
4140

41+
// Without an explicit freshness lifetime, a configured fallback applies only
42+
// when the response supplied caching intent. A missing, empty, or
43+
// extension-only Cache-Control header does not opt the response into caching.
44+
if !cc.HasCachingDirectives() {
45+
return 0, false
46+
}
47+
4248
if defaultTTL <= 0 {
4349
return 0, false
4450
}

v2/pkg/caching/cachecontrol_test.go

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,57 @@ 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+
// A recognized caching directive without a freshness lifetime opts into
36+
// the configured fallback. Public is one such directive, not a requirement.
37+
{name: "public alone", cacheControl: "public", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
38+
{name: "must-revalidate without public", cacheControl: "must-revalidate", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
39+
{name: "proxy-revalidate without public", cacheControl: "proxy-revalidate", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
40+
{name: "bare stale-if-error without public", cacheControl: "stale-if-error", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
41+
{name: "stale-if-error without public", cacheControl: "stale-if-error=60", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
42+
{name: "stale-while-revalidate without public", cacheControl: "stale-while-revalidate=60", defaultTTL: defaultTTL, wantTTL: defaultTTL, wantOK: true},
43+
44+
// No header or a header without recognized caching intent does not use the
45+
// configured fallback.
46+
{name: "no cache-control header", defaultTTL: defaultTTL, wantOK: false},
47+
{name: "extension directive without public", cacheControl: "cdn-cache-control=60", defaultTTL: defaultTTL, wantOK: false},
48+
{name: "immutable without public", cacheControl: "immutable", defaultTTL: defaultTTL, wantOK: false},
49+
{name: "no-transform without public", cacheControl: "no-transform", defaultTTL: defaultTTL, wantOK: false},
50+
{name: "must-understand without public", cacheControl: "must-understand", defaultTTL: defaultTTL, wantOK: false},
2851

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},
52+
// Explicit refusals win whether public is present or not.
53+
{name: "no-store", cacheControl: "max-age=60, no-store", defaultTTL: defaultTTL, wantOK: false},
54+
{name: "no-cache", cacheControl: "max-age=60, no-cache", defaultTTL: defaultTTL, wantOK: false},
55+
{name: "field-specific no-cache", cacheControl: `max-age=60, no-cache="Set-Cookie"`, defaultTTL: defaultTTL, wantOK: false},
56+
{name: "private", cacheControl: "max-age=60, private", defaultTTL: defaultTTL, wantOK: false},
57+
{name: "private wins over public", cacheControl: "public, max-age=60, private", defaultTTL: defaultTTL, wantOK: false},
3458

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},
59+
// A lifetime of zero is a refusal, and malformed lifetimes cannot opt a
60+
// response into caching.
61+
{name: "zero max-age", cacheControl: "max-age=0", defaultTTL: defaultTTL, wantOK: false},
62+
{name: "zero s-maxage", cacheControl: "s-maxage=0", defaultTTL: defaultTTL, wantOK: false},
63+
{name: "public does not override zero max-age", cacheControl: "public, max-age=0", defaultTTL: defaultTTL, wantOK: false},
64+
{name: "invalid max-age", cacheControl: "max-age=abc", defaultTTL: defaultTTL, wantOK: false},
65+
{name: "invalid stale-while-revalidate", cacheControl: "stale-while-revalidate=abc", defaultTTL: defaultTTL, wantOK: false},
3966

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},
67+
// The fallback must itself be usable.
68+
{name: "caching intent with zero default", cacheControl: "must-revalidate", wantOK: false},
69+
{name: "caching intent with negative default", cacheControl: "must-revalidate", defaultTTL: -time.Second, wantOK: false},
4570
} {
4671
t.Run(tc.name, func(t *testing.T) {
4772
headers := http.Header{}

v2/pkg/engine/cache/cache_control.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,19 @@ type CacheControlResponse struct {
226226
// store the specified field-names(s), whereas it MAY store the
227227
// remainder of the response message.
228228
Private *FieldNames
229+
230+
// hasAdditionalCachingDirective records recognized response caching
231+
// directives whose behavior is not otherwise modeled by this type. It lets
232+
// callers distinguish a response that supplied caching intent from a missing,
233+
// empty, or extension-only Cache-Control header.
234+
hasAdditionalCachingDirective bool
235+
}
236+
237+
// HasCachingDirectives reports whether the response contained a recognized
238+
// directive that affects caching.
239+
func (c *CacheControlResponse) HasCachingDirectives() bool {
240+
return c.MaxAge != nil || c.SMaxAge != nil || c.NoStore || c.NoCache != nil ||
241+
c.Public || c.Private != nil || c.hasAdditionalCachingDirective
229242
}
230243

231244
// ToHeaderString converts a CacheControlResponse to a Cache-Control header string.
@@ -412,6 +425,25 @@ func parseIdent(name token, l *lexer, cc *CacheControlResponse) error {
412425
}
413426
fieldNamesArgument(arg, cc.Private)
414427

428+
case "must-revalidate", "proxy-revalidate":
429+
cc.hasAdditionalCachingDirective = true
430+
431+
case "stale-if-error":
432+
// Retain compatibility with senders that use the bare form. With a value,
433+
// only a valid delta-seconds carries caching intent.
434+
if !arg.present {
435+
cc.hasAdditionalCachingDirective = true
436+
break
437+
}
438+
if _, err := deltaSecondsArgument("stale-if-error", arg); err == nil {
439+
cc.hasAdditionalCachingDirective = true
440+
}
441+
442+
case "stale-while-revalidate":
443+
if _, err := deltaSecondsArgument("stale-while-revalidate", arg); err == nil {
444+
cc.hasAdditionalCachingDirective = true
445+
}
446+
415447
default:
416448
// We ignore directives that we don't specify here.
417449
}

v2/pkg/engine/cache/cache_control_test.go

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,21 +37,25 @@ import (
3737
//
3838
// D1 "No directives" is never an error, however it arises. An empty field, a
3939
// whitespace-only field, a field of nothing but commas, and a field of
40-
// nothing but directives we do not model all parse to a non-nil, zero
40+
// nothing but unknown extension directives all parse to a non-nil, zero
4141
// CacheControlResponse. ParseCacheControlResponse never returns nil on
42-
// success either, so callers never have to nil-check.
42+
// success either, so callers never have to nil-check. Standard caching
43+
// directives that are not otherwise modeled can still contribute to
44+
// HasCachingDirectives — see D3.
4345
//
4446
// This was three inconsistent rules before: "" and " " were errors,
4547
// "," was an error, but `must-revalidate` produced a non-nil zero value —
4648
// three inputs carrying zero directives, three different outcomes. Under
4749
// D0 none of them is a syntax problem, so none of them errors. See D13.
4850
// D2 Directive names are case-insensitive (RFC 9110 tokens).
49-
// D3 Directives that the CacheControlResponse struct does not model
50-
// (must-revalidate, no-transform, proxy-revalidate, immutable,
51-
// stale-while-revalidate, arbitrary cache extensions) are ignored, not
52-
// rejected. RFC 9111 §5.2: unknown directives MUST be ignored. A header
53-
// consisting only of such directives still parses to a non-nil, zero
54-
// CacheControlResponse.
51+
// D3 Directives that the CacheControlResponse struct does not otherwise model
52+
// are not rejected. Recognized directives that carry caching intent
53+
// (must-revalidate, proxy-revalidate, stale-if-error and
54+
// stale-while-revalidate) contribute to HasCachingDirectives so a caller
55+
// can decide whether to apply a configured freshness lifetime. Directives
56+
// with no storage or freshness effect here (no-transform, immutable and
57+
// must-understand) and arbitrary extensions are ignored. RFC 9111 §5.2:
58+
// unknown directives MUST be ignored.
5559
// D4 A boolean directive (no-store, public) that carries an argument keeps
5660
// its meaning; the stray argument is discarded. `no-store=true` still
5761
// sets NoStore, since the argument tells us nothing the directive name
@@ -245,14 +249,14 @@ func TestParse(t *testing.T) {
245249
requireParses(t, "private", &CacheControlResponse{Private: fieldNames()})
246250
})
247251

248-
// --- D3: unmodelled directives are ignored ------------------------------
252+
// --- D3: directives without modeled fields -------------------------------
249253

250-
t.Run("must-revalidate alone is ignored but still parses", func(t *testing.T) {
254+
t.Run("must-revalidate has no modeled field", func(t *testing.T) {
251255
t.Parallel()
252256
requireParses(t, "must-revalidate", &CacheControlResponse{})
253257
})
254258

255-
t.Run("stale-while-revalidate is ignored", func(t *testing.T) {
259+
t.Run("stale-while-revalidate has no modeled field", func(t *testing.T) {
256260
t.Parallel()
257261
requireParses(t, "max-age=60, stale-while-revalidate=30", &CacheControlResponse{MaxAge: seconds(60)})
258262
})
@@ -982,10 +986,8 @@ func TestParse(t *testing.T) {
982986
func TestParseCacheControlResponse(t *testing.T) {
983987
t.Parallel()
984988

985-
// An absent header and a header carrying no directives both yield an empty
986-
// response rather than nil, so callers never have to nil-check. Nothing is
987-
// lost: every field is already optional, so "absent" and "present but
988-
// modelled nothing" are the same answer to every question a caller asks.
989+
// An absent header and a header carrying no recognized directives both yield
990+
// an empty response rather than nil, so callers never have to nil-check.
989991

990992
t.Run("nil headers", func(t *testing.T) {
991993
t.Parallel()
@@ -1002,14 +1004,14 @@ func TestParseCacheControlResponse(t *testing.T) {
10021004
requireHeaderParses(t, http.Header{"Cache-Control": []string{""}}, &CacheControlResponse{})
10031005
})
10041006

1005-
// D1 + D3 — a field we understood but modelled none of still parsed, so it
1006-
// is not the "absent" case and must not be nil.
1007-
t.Run("Cache-Control with only unmodelled directives is non-nil", func(t *testing.T) {
1007+
// D1 + D3 — a recognized caching directive can carry intent even when its
1008+
// individual behavior is not otherwise modeled.
1009+
t.Run("Cache-Control with an additional caching directive retains intent", func(t *testing.T) {
10081010
t.Parallel()
1009-
requireHeaderParses(t,
1010-
http.Header{"Cache-Control": []string{"must-revalidate"}},
1011-
&CacheControlResponse{},
1012-
)
1011+
1012+
got, err := ParseCacheControlResponse(http.Header{"Cache-Control": []string{"must-revalidate"}})
1013+
require.NoError(t, err)
1014+
require.True(t, got.HasCachingDirectives())
10131015
})
10141016

10151017
t.Run("valid Cache-Control", func(t *testing.T) {

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)