repos / pico

pico services mono repo
git clone https://github.com/picosh/pico.git

pico / pkg / httpcache
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}