Eric Bower
·
2026-08-13
1package httpcache
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/hashicorp/golang-lru/v2/expirable"
14)
15
16var ErrMustRevalidate = errors.New("cache is stale and must-revalidate requires revalidation")
17
18type CacheKey interface {
19 GetCacheKey(r *http.Request) string
20}
21
22type DefaultCacheKey struct{}
23
24func (p *DefaultCacheKey) GetCacheKey(r *http.Request) string {
25 // RFC 9111 ยง3: HEAD responses can be served from a stored GET response.
26 // Normalize HEAD to GET so both methods share the same cache entry.
27 method := r.Method
28 if method == http.MethodHead {
29 method = http.MethodGet
30 }
31 return r.Host + "__" + method + "__" + r.URL.RequestURI()
32}
33
34type CacheMetrics interface {
35 AddCacheItem(float64)
36 AddCacheHit()
37 AddCacheMiss()
38 AddUpstreamRequest()
39}
40
41type DefaultCacheMetrics struct{}
42
43func (p *DefaultCacheMetrics) AddCacheItem(float64) {}
44func (p *DefaultCacheMetrics) AddCacheHit() {}
45func (p *DefaultCacheMetrics) AddCacheMiss() {}
46func (p *DefaultCacheMetrics) AddUpstreamRequest() {}
47
48type HttpCache struct {
49 CacheKey
50 CacheMetrics
51 Ttl time.Duration
52 Upstream http.Handler
53 Cache Cacher
54 Logger *slog.Logger
55}
56
57func NewHttpCache(log *slog.Logger, upstream http.Handler) *HttpCache {
58 ttl := time.Minute * 10
59 cache := expirable.NewLRU[string, []byte](0, nil, ttl)
60 httpCache := &HttpCache{
61 Ttl: ttl,
62 Logger: log,
63 Upstream: upstream,
64 Cache: cache,
65 CacheKey: &DefaultCacheKey{},
66 CacheMetrics: &DefaultCacheMetrics{},
67 }
68 log.Info("httpcache initiated", "ttl", httpCache.Ttl, "storage", "lru")
69 return httpCache
70}
71
72func (c *HttpCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
73 if c.Upstream == nil {
74 http.Error(w, "upstream http handler not found", http.StatusNotFound)
75 return
76 }
77
78 cacheKey := c.GetCacheKey(r)
79 log := c.Logger.With("cache_key", cacheKey)
80
81 err := c.maybeUseCache(cacheKey, w, r)
82 if err == nil {
83 log.Info("cache hit")
84 c.AddCacheHit()
85 return
86 }
87
88 // RFC 9111 5.2.1.7 only-if-cached - don't store new responses
89 cacheContState := parseCacheControl(r.Header.Get("cache-control"))
90 onlyIfCached := cacheContState.onlyIfCache
91 if onlyIfCached {
92 msg := "cache not found and detected only-if-cached"
93 log.Error(msg)
94 http.Error(w, msg, http.StatusGatewayTimeout)
95 return
96 }
97
98 // RFC 9111 4.2.4 + 4.3.1/4.3.2: stale must-revalidate entries must be
99 // revalidated with conditional headers derived from the stored response.
100 // Preserve original client conditional headers so we can evaluate them
101 // after revalidation to decide whether the client gets 304 or 200.
102 clientIfNoneMatch := r.Header.Get("if-none-match")
103 clientIfModifiedSince := r.Header.Get("if-modified-since")
104 clientConditional := clientIfNoneMatch != "" || clientIfModifiedSince != ""
105
106 if errors.Is(err, ErrMustRevalidate) {
107 if cachedData, exists := c.Cache.Get(cacheKey); exists {
108 var cachedValue CacheValue
109 if json.Unmarshal(cachedData, &cachedValue) == nil {
110 if etag := getHeader(cachedValue.Header, "etag"); etag != "" {
111 r.Header.Set("if-none-match", etag)
112 }
113 if lastMod := getHeader(cachedValue.Header, "last-modified"); lastMod != "" {
114 r.Header.Set("if-modified-since", lastMod)
115 }
116 }
117 }
118 }
119
120 log.Info("cache miss, requesting upstream", "err", err)
121 c.AddCacheMiss()
122 wrapped := &responseWriter{ResponseWriter: w}
123 c.Upstream.ServeHTTP(wrapped, r)
124 c.AddUpstreamRequest()
125
126 // RFC 9111 4.3.4 304 Not Modified
127 // https://www.rfc-editor.org/rfc/rfc9111.html#section-4.3.4
128 // A 304 response updates header metadata but preserves the cached body.
129 if wrapped.StatusCode() == http.StatusNotModified {
130 existingData, exists := c.Cache.Get(cacheKey)
131 if !exists {
132 // Cache entry vanished; forward the 304 as-is.
133 log.Info("no cache entry found, forwarding 304 as-is")
134 wrapped.Send()
135 return
136 }
137
138 var cacheValue CacheValue
139 err = json.Unmarshal(existingData, &cacheValue)
140 if err != nil {
141 log.Error("json unmarshal", "err", err)
142 wrapped.Send()
143 return
144 }
145
146 // Merge non-forbidden headers from the 304 response into the cached entry.
147 // Normalize keys to lowercase to avoid case-sensitivity issues.
148 // Delete any existing case-insensitive duplicates first so that getHeader
149 // cannot find both the old and new values on random map iteration.
150 for key, values := range wrapped.Header() {
151 if isForbiddenHeader(key) {
152 continue
153 }
154 normKey := strings.ToLower(key)
155 for existing := range cacheValue.Header {
156 if strings.EqualFold(existing, normKey) {
157 delete(cacheValue.Header, existing)
158 }
159 }
160 cacheValue.Header[normKey] = values
161 }
162 // Revalidation refreshes the entry -- reset CreatedAt so it's fresh again.
163 cacheValue.CreatedAt = time.Now()
164 enc, _ := json.Marshal(cacheValue)
165 log.Info("updating cached headers from 304 response")
166 c.Cache.Remove(cacheKey)
167 c.Cache.Add(cacheKey, enc)
168 c.AddCacheItem(float64(len(enc)))
169
170 if clientConditional {
171 // Client sent conditional headers -- re-evaluate against the
172 // updated cached entry and return 304 if it still matches.
173 r.Header.Set("if-none-match", clientIfNoneMatch)
174 r.Header.Set("if-modified-since", clientIfModifiedSince)
175 valid := c.handleValidation(r, &cacheValue)
176 if valid {
177 hdr := stripForbiddenHeaders(w, &cacheValue)
178 ageDur := calcAge(cacheValue.CreatedAt)
179 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
180 hdr.Set("cache-status", cacheStatusStale(cacheKey, wrapped.StatusCode()))
181 w.WriteHeader(http.StatusNotModified)
182 log.Info("client conditional headers match, returning 304")
183 return
184 }
185 }
186
187 // Client request was unconditional (or conditional but no longer matches)
188 // serve the full cached response.
189 log.Info("serving full cached response to client")
190 serveCache(w, c.Ttl, cacheKey, &cacheValue)
191 return
192 }
193
194 err = isResponseCachable(r, wrapped)
195 if err == nil {
196 log.Info("storing cache")
197 nextValue := wrapped.ToCacheValue(r)
198 enc, _ := json.Marshal(nextValue)
199 c.Cache.Remove(cacheKey)
200 c.Cache.Add(cacheKey, enc)
201 c.AddCacheItem(float64(len(enc)))
202 wrapped.Header().Set("cache-status", cacheStatusMiss(cacheKey, true))
203 } else {
204 log.Info("not cachable", "err", err)
205 wrapped.Header().Set("cache-status", cacheStatusMiss(cacheKey, false))
206 }
207
208 wrapped.Send()
209}
210
211// isForbiddenHeader checks if a header should not be stored/served per RFC 9111 Section 3.1
212// https://www.rfc-editor.org/rfc/rfc9111.html#section-3.1
213func isForbiddenHeader(key string) bool {
214 switch strings.ToLower(key) {
215 case "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
216 "te", "trailer", "transfer-encoding", "upgrade", "proxy-connection",
217 "proxy-authentication-info":
218 return true
219 default:
220 return false
221 }
222}
223
224func serveCache(w http.ResponseWriter, freshness time.Duration, cacheKey string, cacheValue *CacheValue) {
225 hdr := stripForbiddenHeaders(w, cacheValue)
226 ageDur := calcAge(cacheValue.CreatedAt)
227 age := ageDur.Seconds()
228 hdr.Set("age", strconv.Itoa(int(age)+1))
229 hdr.Set("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
230 statusCode := cacheValue.StatusCode
231 if statusCode == 0 {
232 statusCode = http.StatusOK
233 }
234 w.WriteHeader(statusCode)
235 _, _ = w.Write(cacheValue.Body)
236}
237
238// matchVary checks if the request matches the Vary header from the cached response.
239// RFC 9111 4.1 Vary.
240//
241// Vary lists *request* header names, so we compare the incoming request headers
242// against the request header values that were snapshotted when the entry was
243// stored (CacheValue.VaryRequestHeaders). Comparing against response headers
244// (the old approach) silently skipped every field because request header names
245// never appear in response header maps.
246func matchVary(r *http.Request, cacheValue *CacheValue) bool {
247 vary := getHeader(cacheValue.Header, "Vary")
248 if vary == "" {
249 return true
250 }
251
252 // Vary: * means the response is not cacheable by a shared cache.
253 if vary == "*" {
254 return false
255 }
256
257 // If the entry predates VaryRequestHeaders (legacy/zero value), fall back to
258 // treating it as a miss so the cache re-populates with a fresh entry.
259 if len(cacheValue.VaryRequestHeaders) == 0 {
260 return false
261 }
262
263 fields := strings.FieldsFunc(vary, func(c rune) bool { return c == ',' })
264 for _, field := range fields {
265 field = strings.TrimSpace(strings.ToLower(field))
266 if field == "" {
267 continue
268 }
269 cachedReqVal, known := cacheValue.VaryRequestHeaders[field]
270 if !known {
271 // Field listed in Vary but not recorded โ treat as miss.
272 return false
273 }
274 if r.Header.Get(field) != cachedReqVal {
275 return false
276 }
277 }
278
279 return true
280}
281
282func getHeader(headers map[string][]string, key string) string {
283 // Case-insensitive lookup
284 for k, values := range headers {
285 if strings.EqualFold(k, key) && len(values) > 0 {
286 return values[0]
287 }
288 }
289 return ""
290}
291
292// handleValidation handles conditional request validation.
293// RFC 9110 13 Conditional Requests.
294// RFC 9111 4.3.2 Response Validation.
295func (c *HttpCache) handleValidation(r *http.Request, cacheValue *CacheValue) bool {
296 etag := getHeader(cacheValue.Header, "etag")
297 lastModified := getHeader(cacheValue.Header, "last-modified")
298
299 c.Logger.Debug(
300 "validate",
301 "etag", etag,
302 "lastModified", lastModified,
303 )
304
305 // RFC 9110 13.1.2 If-None-Match
306 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.2
307 ifNoneMatch := r.Header.Get("if-none-match")
308 if ifNoneMatch != "" {
309 // Wildcard If-None-Match: *
310 if ifNoneMatch == "*" {
311 return etag != ""
312 }
313
314 // Check if any of the provided ETags match
315 etags := parseETags(ifNoneMatch)
316 for _, etagVal := range etags {
317 if etagVal == etag {
318 return true
319 }
320 }
321 return false
322 }
323
324 // RFC 9110 13.1.3 If-Modified-Since
325 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.3
326 ifModifiedSince := r.Header.Get("if-modified-since")
327 if ifModifiedSince != "" && lastModified != "" {
328 reqTime := parseTimeFallback(ifModifiedSince)
329 if !reqTime.IsZero() {
330 cachedTime := parseTimeFallback(lastModified)
331 if !cachedTime.IsZero() {
332 if !cachedTime.After(reqTime) {
333 return true
334 }
335 }
336 }
337 }
338
339 // RFC 9110 13.1.4 If-Unmodified-Since
340 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13.1.4
341 // For cache purposes, if If-Unmodified-Since matches, we can serve from cache
342 ifUnmodifiedSince := r.Header.Get("if-unmodified-since")
343 if ifUnmodifiedSince != "" && lastModified != "" {
344 reqTime := parseTimeFallback(ifUnmodifiedSince)
345 if !reqTime.IsZero() {
346 cachedTime := parseTimeFallback(lastModified)
347 if !cachedTime.IsZero() {
348 if !cachedTime.Before(reqTime) {
349 // Cached response is not modified since the request time
350 // We can serve from cache, but don't return 304
351 // The caller will handle the cache hit
352 return false
353 }
354 }
355 }
356 }
357
358 return false
359}
360
361func parseETags(etags string) []string {
362 var result []string
363 parts := strings.Split(etags, ",")
364 for _, part := range parts {
365 part = strings.TrimSpace(part)
366 if part != "" {
367 result = append(result, part)
368 }
369 }
370 return result
371}
372
373// parseTimeFallback parses time strings in RFC1123 or RFC1123Z format.
374func parseTimeFallback(t string) time.Time {
375 // Try RFC1123Z first (with numeric timezone)
376 if parsed, err := http.ParseTime(t); err == nil {
377 return parsed
378 }
379 // Try RFC1123 (with 3-letter timezone abbreviation)
380 parsed, err := time.Parse("Mon, 02 Jan 2006 15:04:05 MST", t)
381 if err == nil {
382 return parsed
383 }
384 return time.Time{}
385}
386
387func isResponseCachable(r *http.Request, resp *responseWriter) error {
388 method := r.Method
389 // RFC 9111 2.3 Opinion - Only cache GET requests
390 if method != http.MethodGet {
391 return fmt.Errorf("response method not cacheable: %s", method)
392 }
393
394 isValidStatus := isCacheableStatusCode(resp.StatusCode())
395 if !isValidStatus {
396 return fmt.Errorf("response status code not cachable: %d", resp.StatusCode())
397 }
398
399 state := parseCacheControl(resp.Header().Get("cache-control"))
400 if state.private {
401 return fmt.Errorf("shared cache cannot store private directives")
402 }
403
404 return nil
405}
406
407// RFC 9110 15.1-2: Heuristically cachable status codes
408// 200, 203, 204,
409// 206, 300, 301,
410// 308, 404, 405,
411// 410, 414, 501.
412func isCacheableStatusCode(code int) bool {
413 switch code {
414 case http.StatusOK, http.StatusNonAuthoritativeInfo, http.StatusNoContent,
415 http.StatusPartialContent, http.StatusMultipleChoices, http.StatusMovedPermanently,
416 http.StatusPermanentRedirect, http.StatusNotFound, http.StatusMethodNotAllowed,
417 http.StatusGone, http.StatusRequestURITooLong, http.StatusNotImplemented:
418 return true
419 default:
420 return false
421 }
422}
423
424type cacheControlState struct {
425 noCache bool
426 noStore bool
427 noTransform bool
428 onlyIfCache bool
429 private bool
430 public bool
431 mustRevalidate bool
432 // we explicitly check for max-age == 0 which is different from it
433 // being unset so it's important we check if it is actually set
434 // in the cache-control
435 hasMaxAge bool
436 maxAge time.Duration
437 sharedMaxAge time.Duration
438 maxStale time.Duration
439 minFresh time.Duration
440}
441
442func parseCacheControl(cc string) cacheControlState {
443 parsed := strings.Split(cc, ",")
444 state := cacheControlState{}
445 for _, raw := range parsed {
446 directive := strings.ToLower(strings.TrimSpace(raw))
447 if directive == "" {
448 continue
449 }
450 switch directive {
451 case "public":
452 state.public = true
453 case "private":
454 state.private = true
455 case "no-cache":
456 state.noCache = true
457 case "no-store":
458 state.noStore = true
459 case "no-transform":
460 state.noTransform = true
461 case "only-if-cached":
462 state.onlyIfCache = true
463 case "must-revalidate":
464 state.mustRevalidate = true
465 }
466
467 if strings.HasPrefix(directive, "max-age=") {
468 state.hasMaxAge = true
469 state.maxAge = parseHeaderTime(directive, "max-age")
470 }
471 if strings.HasPrefix(directive, "s-maxage=") {
472 state.sharedMaxAge = parseHeaderTime(directive, "s-maxage")
473 }
474 if strings.HasPrefix(directive, "min-fresh=") {
475 state.minFresh = parseHeaderTime(directive, "min-fresh")
476 }
477 if strings.HasPrefix(directive, "max-stale=") {
478 state.maxStale = parseHeaderTime(directive, "max-stale")
479 }
480 }
481 return state
482}
483
484func isCacheValid(r *http.Request, freshness time.Duration, age time.Duration) error {
485 state := parseCacheControl(r.Header.Get("cache-control"))
486
487 if state.private {
488 return fmt.Errorf("private directive")
489 }
490
491 // RFC 9111 5.2.1.4 Request Cache-Control: no-cache
492 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.4
493 if state.noCache {
494 return fmt.Errorf("detected no-cache")
495 }
496
497 // RFC 9111 5.2.1.5 Request Cache-Control: no-store
498 // A no-store request can still use cached content, it just shouldn't store the response
499 if state.noStore {
500 // Allow cache hit but won't store on this request
501 return nil
502 }
503
504 // RFC 9111 5.2.1.1 Request Cache-Control: max-age=0
505 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.1
506 if state.hasMaxAge && state.maxAge == 0 {
507 return fmt.Errorf("detected max-age=0")
508 }
509
510 // RFC 9111 5.2.1.3 Request Cache-Control: min-fresh
511 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.3
512 minFreshDur := state.minFresh
513 if minFreshDur.Seconds() > 0 && freshness < minFreshDur {
514 return fmt.Errorf("min-fresh: cache freshness is too old")
515 }
516
517 // RFC 9111 5.2.1.2 Request Cache-Control: max-stale
518 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.2
519 // max-stale allows serving stale responses as long as staleness <= max-stale value
520 // staleness = age - freshness (when freshness < 0, staleness = age + |freshness|)
521 // If freshness <= 0, the cache is stale, and max-stale allows it if staleness <= max-stale
522 maxStaleDur := state.maxStale
523 if maxStaleDur > 0 && freshness <= 0 {
524 // Cache is stale, check if max-stale allows it
525 staleness := age - freshness // When freshness <= 0, staleness = age + |freshness|
526 if staleness > maxStaleDur {
527 return fmt.Errorf("max-stale: staleness exceeds limit")
528 }
529 // max-stale allows this stale response
530 return nil
531 }
532 if maxStaleDur > 0 && freshness > maxStaleDur {
533 return fmt.Errorf("max-stale: freshness exceeds limit")
534 }
535
536 // RFC 9111 5.2.1.6 Request Cache-Control: no-transform
537 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.6
538 // no-transform in the request means the cache should not transform the response.
539 // Serving from cache counts as a transformation, so we must forward to origin.
540 if state.noTransform {
541 return fmt.Errorf("request has no-transform directive")
542 }
543
544 // RFC 9111 5.2.1.7 Request Cache-Control: only-if-cached
545 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.1.7
546 // For our implementation, only-if-cached means we can use cached response
547 // but we shouldn't store new responses. The caller handles the logic.
548 if state.onlyIfCache {
549 // Allow cache hit, but the ServeHTTP will not store new responses
550 return nil
551 }
552
553 return nil
554}
555
556func (c *HttpCache) maybeUseCache(cacheKey string, w http.ResponseWriter, r *http.Request) error {
557 data, exists := c.Cache.Get(cacheKey)
558 if !exists {
559 return fmt.Errorf("no cache stored")
560 }
561
562 var cacheValue CacheValue
563 err := json.Unmarshal(data, &cacheValue)
564 if err != nil {
565 return fmt.Errorf("json unmarshal: %w", err)
566 }
567
568 // RFC 9111 4.1 Vary - check if request matches cached Vary values
569 if !matchVary(r, &cacheValue) {
570 return fmt.Errorf("vary mismatch")
571 }
572
573 // RFC 9111 5.2.2.4 Response Cache-Control: no-cache
574 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.4
575 // Must revalidate with origin before using cached response
576 cacheContState := parseCacheControl(
577 getHeader(cacheValue.Header, "cache-control"),
578 )
579 if cacheContState.noCache {
580 return fmt.Errorf("cache requires revalidation")
581 }
582
583 // RFC 9111 5.3 Expires
584 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.3
585 // Check if the cached response has expired based on the Expires header
586 var expires time.Time
587 expiresStr := getHeader(cacheValue.Header, "expires")
588 if expiresStr != "" {
589 var parseErr error
590 expires, parseErr = http.ParseTime(expiresStr)
591 if parseErr != nil {
592 // Invalid Expires header means the response is stale
593 return fmt.Errorf("cache expired based on expires header")
594 }
595 if time.Now().After(expires) {
596 return fmt.Errorf("cache expired based on expires header")
597 }
598 }
599
600 // RFC 9111 5.2.2.5 Response Cache-Control: must-revalidate
601 // https://www.rfc-editor.org/rfc/rfc9111.html#section-3.3.1
602 // must-revalidate means the cache MUST NOT use a stale response if it can validate it
603 // with the origin server. When cache is stale, we must revalidate.
604 if cacheContState.mustRevalidate {
605 // Check if cache is stale first
606 age := calcAge(cacheValue.CreatedAt)
607 freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
608 if freshness <= 0 {
609 return ErrMustRevalidate
610 }
611 }
612
613 // RFC 9111 5.2.2.5 Response Cache-Control: no-store
614 // https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2.5
615 // Should not store response, but cached response can still be used
616 // However, tests expect this to forward to origin
617 if cacheContState.noStore {
618 return fmt.Errorf("cache has no-store")
619 }
620
621 age := calcAge(cacheValue.CreatedAt)
622 freshness := calcFreshness(cacheContState, expires, age, c.Ttl)
623
624 // RFC 9111 4.3 Validation - check validation headers first
625 // RFC 9110 13 Conditional Requests
626 // https://www.rfc-editor.org/rfc/rfc9110.html#section-13
627 valid := c.handleValidation(r, &cacheValue)
628 if valid {
629 hdr := stripForbiddenHeaders(w, &cacheValue)
630 ageDur := calcAge(cacheValue.CreatedAt)
631 hdr.Set("age", strconv.Itoa(int(ageDur.Seconds())+1))
632 hdr.Set("cache-status", cacheStatusHit(cacheKey, freshness.Seconds()))
633 w.WriteHeader(http.StatusNotModified)
634 return nil
635 }
636
637 // Check if request allows stale responses (max-stale)
638 // RFC 9111 5.2.1.2 - max-stale allows serving stale responses
639 // We need to check this before the freshness <= 0 check
640 reqCacheState := parseCacheControl(r.Header.Get("cache-control"))
641 maxStaleDur := reqCacheState.maxStale
642 hasMaxStale := maxStaleDur > 0 && freshness <= 0
643
644 isValid := isCacheValid(r, freshness, age)
645 if isValid != nil {
646 return fmt.Errorf("cache invalid: %w", isValid)
647 }
648
649 if freshness <= 0 && !hasMaxStale {
650 c.Cache.Remove(cacheKey)
651 return fmt.Errorf("cache stale")
652 }
653
654 // If request specifies max-age=100 and freshness is 350, the response is too fresh
655 // We need to check: is the response older than maxAge?
656 maxAge := reqCacheState.maxAge
657 if reqCacheState.hasMaxAge && maxAge > 0 && age > maxAge {
658 return fmt.Errorf("response older than request max-age")
659 }
660
661 serveCache(w, freshness, cacheKey, &cacheValue)
662 return nil
663}
664
665// parseHeaderTime extracts a duration value from cache-control header.
666// Supports both underscore and hyphen formats (e.g., max-age or max_age).
667func parseHeaderTime(cc string, prefix string) time.Duration {
668 if cc == "" {
669 return 0
670 }
671 // e.g. max-age=N format (also supports max_age)
672 // Try with hyphen first (standard format), then underscore (alternative format)
673 for _, sep := range []string{"", "-"} {
674 search := prefix + sep + "="
675 if idx := strings.Index(cc, search); idx >= 0 {
676 rest := cc[idx+len(search):]
677 // Find the end of the number (comma or end of string)
678 end := len(rest)
679 for i, ch := range rest {
680 if ch == ',' || ch == ' ' {
681 end = i
682 break
683 }
684 }
685 // Parse the number
686 var age int64
687 _, _ = fmt.Sscanf(rest[:end], "%d", &age)
688 return time.Duration(age) * time.Second
689 }
690 }
691 return 0
692}
693
694// RFC 9111 4.2.1 Calculating Freshness
695// https://www.rfc-editor.org/rfc/rfc9111#section-4.2.1
696func calcFreshness(state cacheControlState, expires time.Time, age time.Duration, defaultTtl time.Duration) time.Duration {
697 ttl := defaultTtl
698 smaxAgeDur := state.sharedMaxAge
699 maxAgeDur := state.maxAge
700 remExpires := time.Until(expires)
701
702 if smaxAgeDur.Seconds() > 0 {
703 ttl = smaxAgeDur
704 } else if maxAgeDur.Seconds() > 0 {
705 ttl = maxAgeDur
706 } else if remExpires > 0 {
707 ttl = remExpires
708 }
709
710 return ttl - age
711}
712
713// RFC 9111 4.2.3 Calculating Age
714// https://www.rfc-editor.org/rfc/rfc9111.html#section-4.2.3
715func calcAge(createdAt time.Time) time.Duration {
716 return time.Since(createdAt)
717}
718
719func cacheStatusHit(cacheKey string, ttl float64) string {
720 // RFC 9211 2.1 Cache-Status hit
721 // https://www.rfc-editor.org/rfc/rfc9211#section-2.1
722 // RFC 9211 2.4 Cache-status ttl
723 // https://www.rfc-editor.org/rfc/rfc9211#section-2.4
724 // RFC 9222 2.7 Cache-status key
725 // https://www.rfc-editor.org/rfc/rfc9211#section-2.7
726 return fmt.Sprintf("pico; hit; ttl=%d; key=%s", int(ttl), cacheKey)
727}
728
729func cacheStatusStale(cacheKey string, originStatus int) string {
730 return fmt.Sprintf("pico; fwd=stale; fwd-status=%d", originStatus)
731}
732
733func cacheStatusMiss(cacheKey string, stored bool) string {
734 // RFC 9211 2.2 Cache-Status fwd
735 // https://www.rfc-editor.org/rfc/rfc9211#section-2.2
736 status := "pico; fwd=uri-miss"
737 if stored {
738 // RFC 9211 2.2 Cache-Status stored
739 // https://www.rfc-editor.org/rfc/rfc9211#section-2.5
740 status = fmt.Sprintf("%s; stored", status)
741 }
742 // RFC 9222 2.7 Cache-status key
743 // https://www.rfc-editor.org/rfc/rfc9211#section-2.7
744 status = fmt.Sprintf("%s; key=%s", status, cacheKey)
745 return status
746}
747
748func stripForbiddenHeaders(w http.ResponseWriter, cacheValue *CacheValue) http.Header {
749 hdr := w.Header()
750 for key, values := range cacheValue.Header {
751 if isForbiddenHeader(key) {
752 continue
753 }
754 hdr[http.CanonicalHeaderKey(key)] = values
755 }
756 return hdr
757}