main pico / pkg / httpcache / serve.go
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}