go 150 lines · 3 tabs

Parsing Cache-Control max-age for a TTL In-Memory Cache in Go

Shared by codesnips Aug 2026
3 tabs
package httpcache

import (
	"strconv"
	"strings"
	"time"
)

// Parse returns the TTL implied by a Cache-Control header value.
// ok is false when the response must not be cached.
func Parse(header string) (ttl time.Duration, ok bool) {
	var maxAge, sMaxAge int
	var haveMax, haveShared bool

	for _, raw := range strings.Split(header, ",") {
		directive := strings.ToLower(strings.TrimSpace(raw))
		if directive == "" {
			continue
		}
		switch {
		case directive == "no-store", directive == "no-cache", directive == "private":
			return 0, false
		case strings.HasPrefix(directive, "max-age"):
			if secs, valid := directiveSeconds(directive); valid {
				maxAge, haveMax = secs, true
			}
		case strings.HasPrefix(directive, "s-maxage"):
			if secs, valid := directiveSeconds(directive); valid {
				sMaxAge, haveShared = secs, true
			}
		}
	}

	if haveShared {
		return time.Duration(sMaxAge) * time.Second, true
	}
	if haveMax {
		return time.Duration(maxAge) * time.Second, true
	}
	return 0, false
}

func directiveSeconds(directive string) (int, bool) {
	parts := strings.SplitN(directive, "=", 2)
	if len(parts) != 2 {
		return 0, false
	}
	secs, err := strconv.Atoi(strings.TrimSpace(parts[1]))
	if err != nil || secs < 0 {
		return 0, false
	}
	return secs, true
}
3 files · go Explain with highlit

This snippet shows how an HTTP client can honor a server's Cache-Control header by parsing its directives into a concrete time-to-live and storing responses in a TTL-aware in-memory cache. The three tabs collaborate: a parser that turns the header text into a time.Duration, a concurrent cache keyed by URL, and a caching transport that ties them together.

In cachecontrol.go, Parse scans the comma-separated directives that make up a Cache-Control value. It lower-cases and trims each token, then handles the two flags that force a zero TTL — no-store and no-cache — before extracting the numeric argument from max-age or the fallback s-maxage. The directiveSeconds helper splits on = and uses strconv.Atoi, guarding against malformed or negative values by returning ok=false. Treating a negative or unparsable age as "not cacheable" is deliberate: a cache that guesses a TTL from a broken header risks serving stale data, so the safe default is to skip caching.

The TTLCache in ttlcache.go is a small map guarded by a sync.RWMutex. Each stored entry records an expiresAt timestamp computed once at insert time rather than a duration, so reads become a cheap time.Now().After comparison. Get takes a read lock for the common path and returns a miss when the entry has expired; Set ignores non-positive TTLs so that uncacheable responses never enter the map. Lazy expiration like this avoids a background sweeper goroutine, at the cost of expired entries lingering in memory until overwritten or explicitly evicted.

In transport.go, CachingTransport implements http.RoundTripper, which lets it drop into any http.Client transparently. It only caches idempotent GET requests, returns a cached *http.Response on a hit, and otherwise delegates to the wrapped transport. After a successful response it calls Parse on the response's own Cache-Control header and stores the body via Set only when the TTL is positive. Because the body is an io.ReadCloser that can be consumed once, cacheResponse buffers it and hands out a fresh bytes.Reader on every retrieval. This pattern is useful when a client wants server-driven caching without pulling in a full HTTP cache library, and it keeps the freshness policy exactly where the origin server defined it.


Related snips

Share this code

Here's the card — post it anywhere.

Parsing Cache-Control max-age for a TTL In-Memory Cache in Go — share card
Link copied