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