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