Skip to content

Commit a0f1e04

Browse files
committed
chore: add HTTP expires header compatibility with "UTC" opt-in via env var
Set HTTPCACHE_ALLOW_UTC_DATETIMEFORMAT=1 to enable parsing of non-standard "UTC" dates.
1 parent 2c86d11 commit a0f1e04

File tree

3 files changed

+59
-7
lines changed

3 files changed

+59
-7
lines changed

internal/entry.go

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"iter"
2525
"net/http"
2626
"net/http/httputil"
27+
"os"
2728
"time"
2829
)
2930

@@ -43,8 +44,30 @@ func (r *Response) DateHeader() time.Time {
4344
return date
4445
}
4546

46-
func (r *Response) ExpiresHeader() RawTime {
47-
return RawTime(r.Data.Header.Get("Expires"))
47+
// Deprecated: This function is a workaround for Kubernetes' handling of "UTC" Expires headers.
48+
func parseHTTPDateCompat(dateStr string) (t time.Time, err error) {
49+
if os.Getenv("HTTPCACHE_ALLOW_UTC_DATETIMEFORMAT") == "1" {
50+
// TODO(bartventer): PR Kubernetes to emit "GMT" per RFC 9110 §5.6.7.
51+
// See k8s.io/kube-openapi/pkg/handler3/handler.go for "UTC" usage.
52+
return time.Parse(time.RFC1123, dateStr)
53+
}
54+
return
55+
}
56+
57+
func (r *Response) ExpiresHeader() (t time.Time, found bool, valid bool) {
58+
expiresStr := r.Data.Header.Get("Expires")
59+
if expiresStr == "" {
60+
return
61+
}
62+
found = true
63+
if t, valid = RawTime(expiresStr).Value(); valid {
64+
return
65+
}
66+
expires, err := parseHTTPDateCompat(expiresStr)
67+
if err != nil || expires.IsZero() {
68+
return time.Time{}, false, false
69+
}
70+
return expires, true, true
4871
}
4972

5073
func (r *Response) WriteTo(w io.Writer) (int64, error) {

internal/entry_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,29 @@ func TestParseResponse(t *testing.T) {
162162
})
163163
}
164164
}
165+
166+
func TestParseHTTPDateCompat(t *testing.T) {
167+
r := Response{
168+
Data: &http.Response{
169+
Header: http.Header{
170+
"Expires": []string{"Mon, 02 Jan 2006 15:04:05 UTC"},
171+
},
172+
},
173+
}
174+
t.Run("Not Enabled", func(t *testing.T) {
175+
_, err := parseHTTPDateCompat(r.Data.Header.Get("Expires"))
176+
testutil.RequireNoError(t, err)
177+
178+
_, _, valid := r.ExpiresHeader()
179+
testutil.AssertTrue(t, !valid)
180+
})
181+
182+
t.Run("Enabled", func(t *testing.T) {
183+
t.Setenv("HTTPCACHE_ALLOW_UTC_DATETIMEFORMAT", "1")
184+
_, err := parseHTTPDateCompat(r.Data.Header.Get("Expires"))
185+
testutil.RequireNoError(t, err)
186+
187+
_, found, valid := r.ExpiresHeader()
188+
testutil.AssertTrue(t, found && valid)
189+
})
190+
}

internal/freshness.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,15 +119,18 @@ func (f *freshnessCalculator) CalculateFreshness(
119119
if maxAge, ok := resCC.MaxAge(); ok && maxAge >= 0 {
120120
usefulLife = maxAge // Response is fresh for max-age seconds
121121
}
122+
122123
if usefulLife == 0 {
123-
if expires, ok := entry.ExpiresHeader().Value(); ok && expires.After(date) {
124+
expires, found, valid := entry.ExpiresHeader()
125+
switch {
126+
case valid && expires.After(date):
127+
// Use Expires header if available
124128
usefulLife = expires.Sub(date)
129+
case !found && (isHeuristicallyCacheableCode(resp.StatusCode) || resCC.Public()):
130+
// Heuristic fallback if allowed by RFC9111 §4.2.2 (only if expires is not set)
131+
usefulLife = heuristicFreshness(resp.Header, date)
125132
}
126133
}
127-
// Heuristic fallback if allowed by RFC9111 §4.2.2
128-
if usefulLife == 0 && (isHeuristicallyCacheableCode(resp.StatusCode) || resCC.Public()) {
129-
usefulLife = heuristicFreshness(resp.Header, date)
130-
}
131134

132135
if reqMaxAge, ok := reqCC.MaxAge(); ok && reqMaxAge > 0 {
133136
usefulLife = min(usefulLife, reqMaxAge) // Client prefers a response no older than max-age

0 commit comments

Comments
 (0)