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