httpcache

package module
v4.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 21, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

httpcache-kit

Go Reference Go Report Card License

中文文档

An RFC 7234-compliant HTTP cache handler for Go. Wraps any http.Handler — typically a httputil.ReverseProxy — with memory or disk storage, Cache-Control parsing, conditional revalidation, RFC 7234 invalidation and optional Prometheus metrics.

Evolved from lox/httpcache (MIT).

Features

  • RFC 7234 caching: freshness, heuristic expiration, revalidation, Vary
  • Invalidation: an unsafe method invalidates the request URI and the URIs named by the response's Location / Content-Location
  • Private and shared profiles: a shared cache refuses private responses and strips private headers
  • Memory, disk and VFS backends: disk storage goes through vfs-kit
  • Bounded: configurable TTL, max size, cleanup interval, LRU eviction
  • Observable, and only if you ask: Prometheus metrics live in the prometheusmetrics subpackage, so importing the root package does not link Prometheus — debug logging via logger-kit
  • Graceful shutdown: background cache writes are tracked and can be awaited

Requirements

  • Go 1.27+ (go.mod declares go 1.27.0)
  • github.com/prometheus/client_golang only if you import prometheusmetrics

The root package depends on logger-kit/v3 and vfs-kit. Prometheus and metrics-kit/v3 are reached only through the prometheusmetrics subpackage, so a service that does not export metrics links neither — 51 fewer packages and a 32.5% smaller binary than v3. Applications written against the logger-kit/v2 or metrics-kit/v2 types should remain on github.com/soulteary/httpcache-kit/v2, and applications still on the v1 kit ecosystem on github.com/soulteary/httpcache-kit v1.

Installation

go get github.com/soulteary/httpcache-kit/v4

Quick Start

Shared cache (reverse proxy)
package main

import (
    "log"
    "net/http"
    "net/http/httputil"

    httpcache "github.com/soulteary/httpcache-kit/v3"
)

func main() {
    proxy := &httputil.ReverseProxy{
        Director: func(r *http.Request) {},
    }

    // NewSharedHandler, not NewHandler: this cache serves more than one user.
    handler := httpcache.NewSharedHandler(httpcache.NewMemoryCache(), proxy)

    log.Print("proxy listening on http://localhost:8080")
    log.Fatal(http.ListenAndServe(":8080", handler))
}
Private cache (single user)
handler := httpcache.NewHandler(httpcache.NewMemoryCache(), upstream)
Disk-backed, with options
cache, err := httpcache.NewDiskCacheWithConfig("/var/cache/myproxy",
    httpcache.DefaultCacheConfig().
        WithMaxSize(2 * 1024 * 1024 * 1024).
        WithTTL(24 * time.Hour).
        WithCleanupInterval(30 * time.Minute))
if err != nil {
    log.Fatal(err)
}
defer cache.Close()

handler := httpcache.NewHandlerWithOptions(cache, proxy, &httpcache.HandlerOptions{
    Logger: myLogger,
})
handler.Shared = true

Shared vs Private

This is the one decision to get right, because the unsafe default is the one a field can be forgotten in.

NewHandler NewSharedHandler
Handler.Shared false true
Correct for a per-user cache a reverse proxy, any cache serving more than one user
Cache-Control: private stored not stored
Responses to authorized requests stored not stored unless marked public or carrying s-maxage
Headers listed in private kept stripped before storing
s-maxage ignored honoured over max-age

A shared cache running with Shared: false will serve one user's private response to another. Prefer the constructor over setting the field — a constructor cannot be forgotten.

Backends

httpcache.NewMemoryCache()                              // Cache
httpcache.NewMemoryCacheWithConfig(cfg)                 // ExtendedCache
httpcache.NewDiskCache("/var/cache/x")                  // Cache
httpcache.NewDiskCacheWithConfig("/var/cache/x", cfg)   // ExtendedCache
httpcache.NewVFSCache(fs)                               // Cache
httpcache.NewVFSCacheWithConfig(fs, cfg)                // ExtendedCache

Cache is the minimum the handler needs:

type Cache interface {
    Header(key string) (Header, error)
    Retrieve(key string) (*Resource, error)
    Store(res *Resource, keys ...string) error
    Freshen(res *Resource, keys ...string) error
    Invalidate(keys ...string)
}

ExtendedCache adds management, and is what the *WithConfig constructors return:

type ExtendedCache interface {
    Cache
    Stats() CacheStats
    Cleanup() CleanupResult
    Purge() error
    Close() error
}
stats := cache.Stats()
log.Printf("items=%d bytes=%d hits=%d misses=%d stale=%d",
    stats.ItemCount, stats.TotalSize, stats.HitCount, stats.MissCount, stats.StaleCount)

result := cache.Cleanup()
log.Printf("removed %d items (%d bytes, %d stale markers) in %s",
    result.RemovedItems, result.RemovedBytes, result.RemovedStaleEntries, result.Duration)

Retrieve returns ErrNotFoundInCache for a miss — match it with errors.Is. A *Resource it returns owns a file handle on the disk backend, so close it.

On-disk layout

The disk backend keeps four things under its directory:

body/v1/<hashed-key>      response body
header/v1/<hashed-key>    status line, headers, and the store timestamp
staging/v1/               entries being written, empty when idle
stale-markers.json        invalidation state

An entry is its body plus its header, and the two are published together by rename, so a reader sees the whole previous entry or the whole new one. That holds between goroutines sharing one live cache, and no further:

  • The two renames are sequential. A process that exits between them leaves the new body beside the old header, and startup removes the leftover staging file rather than repairing the pair.
  • The lock is per-instance. Two caches opened on one directory do not coordinate, and their publications can interleave.

Nothing under staging/v1 is a cache entry: it is never scanned, and whatever an interrupted process left there is removed at startup.

Only body/v1 and header/v1 count toward MaxSize and Stats().TotalSize. The other Stats() fields are independent of these directories — StaleCount tracks stale-markers.json, and HitCount/MissCount are counters.

Configuration

cfg := httpcache.DefaultCacheConfig().
    WithMaxSize(10 * 1024 * 1024 * 1024).
    WithTTL(7 * 24 * time.Hour).
    WithCleanupInterval(1 * time.Hour).
    WithStaleMapTTL(24 * time.Hour).
    Validate()
Option Default Notes
MaxSize DefaultMaxCacheSize (10 GiB) 0 means unbounded; LRU eviction above it
TTL DefaultCacheTTL (7 days) 0 means no TTL
CleanupInterval DefaultCleanupInterval (1 hour) 0 disables the background cycle
StaleMapTTL DefaultStaleMapTTL (24 hours) how long the backend remembers a stale marker

Validate() clamps negatives to zero and restores StaleMapTTL to its default if it is non-positive. It mutates and returns the same config, so it chains.

Handler options
handler := httpcache.NewHandlerWithOptions(cache, upstream, &httpcache.HandlerOptions{
    Logger:         myLogger,          // *logger.Logger; nil uses the package logger
    StaleMarkerTTL: 7 * 24 * time.Hour,
})

StaleMarkerTTL is how long the handler remembers an invalidation for a Cache that does not track staleness itself. It must be at least as long as that cache retains entries: the marker is the only record that older entries are pre-mutation, so expiring it first republishes them as fresh. Zero means DefaultCacheTTL. A cache that keeps its own record ignores this.

Cache Keys and Vary

A key is derived from the effective request URI and the method:

key := httpcache.NewRequestKey(r)           // from a request
key = httpcache.NewKey("GET", u, r.Header)  // explicitly
key = key.ForMethod("HEAD")                 // the sibling key for another method
key = key.Vary(resp.Header.Get("Vary"), r)  // the variant key
keyString := key.String()

The request's own Content-Location does not affect the key. RFC 7234 uses Content-Location, but the response's, and only for invalidation — letting a request choose its key allows a client to park its response under another URL's key, or read another URL's entry.

Key.String() is injective: the Vary section uses a control-byte separator with each value quoted, and since url.URL.String percent-encodes control bytes while strconv.Quote escapes them, neither side of the boundary can contain the delimiter. Keys without Vary are plain and unchanged.

Invalidation

An unsafe method (POST, PUT, DELETE, PATCH) invalidates, per RFC 7234 section 4.4:

  • the effective request URI,
  • the URI in the response's Location header,
  • the URI in the response's Content-Location header,

for both the GET and HEAD keys. Cross-origin targets are ignored, so a response cannot evict another origin's entries.

Metrics

Metrics are opt-in at the import level: the root package knows what to record, not where to send it. Pull in prometheusmetrics and you get Prometheus; leave it out and you do not link Prometheus at all.

import (
    metrics "github.com/soulteary/metrics-kit/v3"

    "github.com/soulteary/httpcache-kit/v4/prometheusmetrics"
)

registry := metrics.NewRegistry("myproxy")
m := prometheusmetrics.New(registry) // also installs itself as the default
handler.SetMetrics(m)

// The process-wide default, which every cache and handler reports through
httpcache.SetDefaultMetrics(m)
m = httpcache.GetDefaultMetrics()

// Feed gauges from a cache's own view
m.UpdateCacheStats(cache.Stats())

It exposes hits, misses, skips, evictions, store and retrieve operations, item count, size in bytes, stale count, cleanup duration, and upstream duration and errors.

To record somewhere else — OpenTelemetry, statsd, a test double — implement httpcache.Metrics. Embed httpcache.NopMetrics to inherit no-ops for the methods you do not need, so a method added in a later release cannot break your recorder:

type hitCounter struct {
    httpcache.NopMetrics
    hits atomic.Int64
}

func (c *hitCounter) RecordCacheHit(string) { c.hits.Add(1) }

GetDefaultMetrics never returns nil — until something calls SetDefaultMetrics, it is a NopMetrics.

Logging

import logger "github.com/soulteary/logger-kit/v3"

httpcache.SetLogger(myLogger)     // package-level logger
httpcache.SetDebugLogging(true)   // verbose cache decisions
on := httpcache.IsDebugLogging()

Response Headers

Header Values Meaning
X-Cache HIT served from cache
MISS fetched from upstream and stored
SKIP not cacheable, or cache bypassed
Proxy-Date HTTP-date when this cache received the response

Cache-Control

cc, err := httpcache.ParseCacheControl("max-age=3600, s-maxage=60, private")
cc, err = httpcache.ParseCacheControlHeaders(resp.Header)

cc.Has("no-store")
value, ok := cc.Get("max-age")
d, err := cc.Duration("max-age")
cc.Add("stale-while-revalidate", "30")
header := cc.String()

Resources

res := httpcache.NewResourceBytes(200, body, header)
res = httpcache.NewResource(200, readSeekCloser, header)

res.Status()
res.Header()
res.Age()
res.Expires()
res.MaxAge(shared)
res.HasExplicitExpiration()
res.HeuristicFreshness()
res.HasValidators()
res.MustValidate(shared)
res.IsStale()
res.MarkStale()
res.LastModified()
res.RemovePrivateHeaders()
res.Via()

Graceful Shutdown

The handler stores responses in the background. Await them before closing the backend, or an in-flight write hits a closed cache:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

if err := handler.Shutdown(ctx); err != nil {
    log.Printf("cache shutdown: %v", err)
}
cache.Close()

httpcache.Writes is a package-level sync.WaitGroup covering every handler's background writes, for tests that need to wait on all of them.

Caveats

  • Conditional requests carrying Range are not cached.
  • Clock is a package-level variable, swappable in tests.

Changelog

Release-by-release detail, with the measured numbers behind each claim, lives in CHANGELOG.md.

Upgrade Notes (v4.0.0)

Prometheus moved to the prometheusmetrics subpackage, so the root package no longer links it. If your service does not export metrics, the only change you make is the import path.

What this buys you

Measured for a program importing only the root package, built -trimpath against v3.0.0 and v4.0.0:

v3.0.0 v4.0.0
binary 10,789,883 B 7,284,398 B (−32.5%)
linked packages 281 230 (−51)
modules in go.mod 52 35 (−17)
lines in go.sum 46 36 (−10)
Prometheus/protobuf packages 41 0

A program that does record metrics pays 0.8% more than on v3, for the interface indirection and one extra package. The cost is deferred to the services that want it, not removed.

What you have to change
  1. The import path, everywhere:

    -go get github.com/soulteary/httpcache-kit/v3
    +go get github.com/soulteary/httpcache-kit/v4
    
  2. If you use metrics, import the subpackage and rename two identifiers:

    v3 v4
    httpcache.CacheMetrics (struct) prometheusmetrics.Metrics
    httpcache.NewCacheMetrics(reg) prometheusmetrics.New(reg)
    +import "github.com/soulteary/httpcache-kit/v4/prometheusmetrics"
    
    -m := httpcache.NewCacheMetrics(registry)
    +m := prometheusmetrics.New(registry)
     handler.SetMetrics(m)
    

    httpcache.Metrics is now the interface the cache records through, not a Prometheus struct. SetDefaultMetrics, GetDefaultMetrics and Handler.SetMetrics keep their names and take it.

    There is no deprecated alias, deliberately: an alias would have to import prometheus/client_golang, which relinks it and gives back the whole benefit.

  3. Delete any nil check on GetDefaultMetrics. It used to return a *CacheMetrics that was nil until metrics were registered, and nil-receiver methods made that safe. An interface has no such courtesy — a method call on a nil interface panics — so "unset" is now a real object, NopMetrics:

    -if m := httpcache.GetDefaultMetrics(); m != nil {
    -    m.RecordCacheHit("GET")
    -}
    +httpcache.GetDefaultMetrics().RecordCacheHit("GET")
    

    SetDefaultMetrics(nil) installs NopMetrics rather than arming a nil.

Cache behaviour, the handler, the backends, cache keys, invalidation and the recorded metric names, labels and buckets are all unchanged — the Prometheus constructors were moved verbatim.

Why logging was not split the same way

logger-kit accounts for 5 of the remaining packages, and the handler logs on paths the cache cannot report any other way. A logger is not optional the way a metrics exporter is, so splitting it would cost an interface and an import for almost nothing.

Upgrade Notes (v3.0.0)

The cache's own API did not change. What changed is the module path — this module's and two of its dependencies' — because logger-kit and metrics-kit went to /v3, and the cache API hands you their types.

  1. Change the module path. Every import, in every file:

    go get github.com/soulteary/httpcache-kit/v3
    go mod edit -droprequire github.com/soulteary/httpcache-kit/v2
    
    -httpcache "github.com/soulteary/httpcache-kit/v2"
    +httpcache "github.com/soulteary/httpcache-kit/v3"
    

    go get -u will not do this for you; v2 stays on v2.5.0.

  2. Re-point logger-kit and metrics-kit too, if you name their types. SetLogger, HandlerOptions.Logger and NewCacheMetrics take *logger.Logger and *metrics.Registry, and a v2 type does not satisfy a v3 parameter — the module path is part of the type's identity. This is the only thing that can fail to compile:

    -logger "github.com/soulteary/logger-kit/v2"
    -metrics "github.com/soulteary/metrics-kit/v2"
    +logger "github.com/soulteary/logger-kit/v3"
    +metrics "github.com/soulteary/metrics-kit/v3"
    

    Every name this cache uses from them — logger.Default, logger.NewDefault, logger.Middleware, logger.MiddlewareConfig, metrics.NewRegistry, metrics.Registry, metrics.HTTPDurationBuckets — kept its signature. If you used a FiberHandler, a NewFiberMiddleware or a SkipFuncFiber field from either kit, those moved to their fiberadapter subpackages; see those kits' own v3 notes.

  3. Nothing else. No name in this package was added, removed or changed. Once the imports compile, you are done.

What this buys you

Those kits moved their Fiber support into fiberadapter subpackages, so their root packages no longer link a web framework — and this cache never used Fiber in the first place. It was carrying the framework because logger-kit/v2 and metrics-kit/v2 reached it:

v2.5.0 v3.0.0
Fiber/fasthttp/compress/msgp packages linked 39 0
packages the library links 340 280
modules in the build list 61 51
// indirect lines in go.mod 24 12

The twelve dropped requirements are gofiber/fiber/v3, gofiber/schema, gofiber/utils/v2, klauspost/compress, molecule-man/go-brrr, philhofer/fwd, tinylib/msgp, valyala/bytebufferpool, valyala/fasthttp, golang.org/x/crypto, golang.org/x/net and golang.org/x/text. Fiber still appears in go list -m all, because logger-kit/v3 and metrics-kit/v3 require it for their own fiberadapter subpackages — but no package from it is compiled into a binary that uses this cache.

If you serve this cache behind Fiber, nothing is lost: you were reaching Fiber through your own import, not through this module.

vfs-kit v1.4.0 → v1.4.2

Data-race fixes in the in-memory filesystem, which is what NewMemoryCache and NewMemoryCacheWithConfig run on. The single filesystem-wide mutex became per-directory locking, and File.FileMode and the compressed-read path now take the read lock they were missing. No API of it that this cache uses changed; v1.4.2 also adds an exported ErrRemoveRoot, which this cache cannot produce — it only ever removes individual entry files, never a filesystem root.

Upgrade Notes (v2.5.0)

No API was added, removed or changed. The disk backend writes entries differently, and there is a new directory inside the cache directory.

  • Entries are published by rename on the disk backend. The previous write opened the target with O_CREATE|O_TRUNC, so new contents were never visible as a unit: on a first store an empty file appeared before any bytes reached it, and on a re-store a perfectly readable entry was emptied for as long as the copy took. Replacing a 128 KiB entry under eight concurrent readers produced 2 misses and 258 reads that saw neither the old nor the new body in full; a watcher caught the header file empty 4543 times across 200 re-stores. A reader sharing the cache now sees the whole previous entry or the whole new one — see the on-disk layout section for the two limits on that, a crash between the two renames and two instances on one directory.
  • A body and its header change together. Retrieve opens the body first and reads the header second, so a publish landing between those two steps used to hand back one version's payload with the other's status and headers — a Content-Length or Content-Encoding describing a body that was no longer there. Under load, 148 of 600 retrievals mismatched. Both files are now renamed under one lock that Retrieve holds across both lookups.
  • staging/v1 is new inside the cache directory. Entries are written there before being renamed into place. It is outside everything that is scanned, so nothing in it is ever mistaken for an entry, and it is swept at startup — a process killed mid-write leaves a file behind that nothing else would reclaim. If you size, back up or rsync the cache directory, include it; it is empty when the cache is idle.
  • Storing costs more, mostly on small entries. Measured per store against v2.4.0: 4 KiB went 193µs → 967µs, 2 MiB went 1.66ms → 2.03ms. Large entries are close to free; the small-entry cost is the extra syscalls against a very short write. Numbers are from a container filesystem and will differ elsewhere.
  • Entry files are not fsynced. Rename is what makes publication atomic; durability across a crash is not what a cache needs, and an entry lost that way is a miss. stale-markers.json is still fsynced, because losing invalidation state would republish superseded content.
  • Memory and other VFS backends are unchanged. The VFS interface has no rename, so they keep the in-place write. The header-completeness check added in v2.4.0 still turns that window into a miss for them.

Upgrade Notes (v2.2.0)

This release changes which entries are served and how they are keyed. One constructor and one option were added; nothing was removed.

  • Invalidation now happens. invalidateResource()'s entire body was a debug log call, so a resource that had been POSTed to, PUT or DELETEd kept being served from cache until its own freshness lifetime expired. It now invalidates the request URI and the same-origin URIs named by the response's Location and Content-Location, for both the GET and HEAD keys. Expect more upstream traffic after unsafe methods — that is the bug being fixed.
  • A request's Content-Location no longer selects the cache key. It did, while the upstream request still used the original URL, so a client could park its own response under a different URL's key (shared cache poisoning) or read another URL's entry. If you deliberately relied on that to alias entries, there is no replacement — it was not a feature.
  • Key.String() changed for keys with Vary. It joined a raw URL and raw header values with ":" and "::", so a crafted URL could collide with a different URL carrying Vary values. The Vary section now uses a quoted, control-byte-separated encoding. Existing cached entries with Vary will miss once and be re-fetched; keys without Vary are unchanged.
  • NewSharedHandler is the constructor for a reverse proxy. Shared defaults to false, which is the correct private-cache profile and the wrong one for a shared cache — and a field can be forgotten in a way a constructor cannot. If you set handler.Shared = true by hand, nothing breaks; new code should use the constructor.
  • The disk backend no longer leaks a file handle per Vary lookup. The primary Resource was overwritten without being closed.
  • CacheControl.String() no longer emits empty entries. It allocated its key slice with make([]string, len(cc)) and then appended, so the output began with len(cc) empty fields.
  • HandlerOptions.StaleMarkerTTL is new. Set it to at least your cache's retention when the cache does not track staleness itself; otherwise an expired marker republishes pre-mutation entries as fresh.

Testing

go test ./...

# With coverage
go test ./... -coverprofile=coverage.out -covermode=atomic
go tool cover -func=coverage.out

References

License

Apache License 2.0 — see LICENSE. Portions derive from lox/httpcache, MIT licensed.

Documentation

Overview

Package httpcache is an RFC 7234 HTTP cache for Go: freshness and heuristic expiration, conditional revalidation, Vary-aware cache keys, invalidation on unsafe methods, and memory, disk and VFS backends behind one Cache interface.

Layout

The root package does not import a metrics backend. It defines Metrics -- what the cache records -- and defaults to NopMetrics, so a service that never exports metrics does not link Prometheus:

  • github.com/soulteary/httpcache-kit/v4/prometheusmetrics -- records into Prometheus via metrics-kit, and with it protobuf.

Measured for a program importing only the root package, v3.0.0 against v4.0.0: 51 fewer linked packages, 17 fewer modules and a 32.5% smaller binary. A program that does use the subpackage pays 0.8% more than it did on v3 -- the cost is deferred, not removed, and only to those who want it.

Logging is not split the same way. The handler logs on paths the cache cannot report any other way, so a logger is not optional in the way a metrics exporter is; logger-kit accounts for 5 of the remaining packages.

Getting started

cache := httpcache.NewMemoryCache()
handler := httpcache.NewHandler(cache, upstream)
handler.Shared = true // a reverse proxy in front of many users

http.ListenAndServe(":8080", handler)

A shared cache refuses to store responses marked private and strips private headers; a private cache does not. Getting this backwards serves one user's response to another, so NewSharedHandler exists to make the choice explicit rather than a field somebody forgets to set.

Recording metrics

Install a recorder once, and every cache and handler in the process reports through it:

registry := metrics.NewRegistry("myproxy")
prometheusmetrics.New(registry) // installs itself as the default

To record somewhere else -- OpenTelemetry, statsd, a test double -- implement Metrics. Embed NopMetrics to inherit no-ops for the methods you do not need, so a method added in a later release cannot break your implementation.

Index

Constants

View Source
const (
	CacheHeader     = "X-Cache"
	ProxyDateHeader = "Proxy-Date"
)
View Source
const (
	CacheControlHeader = "Cache-Control"
)
View Source
const DefaultCacheTTL = 7 * 24 * time.Hour

DefaultCacheTTL is the default TTL for cached items (7 days)

View Source
const DefaultCleanupInterval = 1 * time.Hour

DefaultCleanupInterval is the default interval for cache cleanup (1 hour)

View Source
const DefaultMaxCacheSize int64 = 10 * 1024 * 1024 * 1024

DefaultMaxCacheSize is the default maximum cache size (10 GB)

View Source
const DefaultStaleMapTTL = 24 * time.Hour

DefaultStaleMapTTL is the default TTL for stale map entries (24 hours)

Variables

View Source
var Clock = func() time.Time {
	return time.Now().UTC()
}
View Source
var ErrNotFoundInCache = errors.New("not found in cache")

Returned when a resource doesn't exist

Functions

func IsDebugLogging

func IsDebugLogging() bool

IsDebugLogging returns whether debug messages are logged. Safe for concurrent use.

func SetDebugLogging

func SetDebugLogging(b bool)

SetDebugLogging sets whether debug messages are logged. Safe for concurrent use.

func SetDefaultMetrics

func SetDefaultMetrics(m Metrics)

SetDefaultMetrics installs the process-wide recorder. Passing nil restores NopMetrics rather than arming a nil that would panic on first use.

prometheusmetrics.New calls this for you.

func SetLogger

func SetLogger(log *logger.Logger)

SetLogger sets the package-level logger used when Handler has no Logger injected. Prefer passing Logger via NewHandlerWithOptions; SetLogger is retained for backward compatibility.

Types

type Cache

type Cache interface {
	Header(key string) (Header, error)
	Store(res *Resource, keys ...string) error
	Retrieve(key string) (*Resource, error)
	Invalidate(keys ...string)
	Freshen(res *Resource, keys ...string) error
}

func NewDiskCache

func NewDiskCache(dir string) (Cache, error)

NewDiskCache returns a disk-backed cache

func NewMemoryCache

func NewMemoryCache() Cache

NewMemoryCache returns an ephemeral cache in memory

func NewVFSCache

func NewVFSCache(fs vfs.VFS) Cache

NewVFSCache returns a cache backend off the provided VFS

type CacheConfig

type CacheConfig struct {
	// MaxSize is the maximum size of the cache in bytes.
	// When exceeded, the oldest items will be evicted (LRU).
	// Set to 0 to disable size-based eviction.
	MaxSize int64

	// TTL is the time-to-live for cached items.
	// Items older than this will be evicted during cleanup.
	// Set to 0 to disable TTL-based eviction.
	TTL time.Duration

	// CleanupInterval is the interval between automatic cleanup runs.
	// Set to 0 to disable automatic cleanup.
	CleanupInterval time.Duration

	// StaleMapTTL is the TTL for stale map entries.
	// Stale entries older than this will be removed.
	StaleMapTTL time.Duration
}

CacheConfig holds configuration for cache behavior

func DefaultCacheConfig

func DefaultCacheConfig() *CacheConfig

DefaultCacheConfig returns a CacheConfig with sensible defaults

func (*CacheConfig) Validate

func (c *CacheConfig) Validate() *CacheConfig

Validate validates the configuration and applies defaults where needed

func (*CacheConfig) WithCleanupInterval

func (c *CacheConfig) WithCleanupInterval(interval time.Duration) *CacheConfig

WithCleanupInterval sets the cleanup interval

func (*CacheConfig) WithMaxSize

func (c *CacheConfig) WithMaxSize(size int64) *CacheConfig

WithMaxSize sets the maximum cache size

func (*CacheConfig) WithStaleMapTTL

func (c *CacheConfig) WithStaleMapTTL(ttl time.Duration) *CacheConfig

WithStaleMapTTL sets the stale map TTL

func (*CacheConfig) WithTTL

func (c *CacheConfig) WithTTL(ttl time.Duration) *CacheConfig

WithTTL sets the TTL for cached items

type CacheControl

type CacheControl map[string][]string

func ParseCacheControl

func ParseCacheControl(input string) (CacheControl, error)

func ParseCacheControlHeaders

func ParseCacheControlHeaders(h http.Header) (CacheControl, error)

func (CacheControl) Add

func (cc CacheControl) Add(key, val string)

func (CacheControl) Duration

func (cc CacheControl) Duration(key string) (time.Duration, error)

func (CacheControl) Get

func (cc CacheControl) Get(key string) (string, bool)

func (CacheControl) Has

func (cc CacheControl) Has(key string) bool

func (CacheControl) String

func (cc CacheControl) String() string

type CacheStats

type CacheStats struct {
	// TotalSize is the total size of cached items in bytes
	TotalSize int64
	// ItemCount is the number of cached items
	ItemCount int
	// StaleCount is the number of stale map entries
	StaleCount int
	// HitCount is the number of cache hits
	HitCount int64
	// MissCount is the number of cache misses
	MissCount int64
}

CacheStats holds cache statistics

type CleanupResult

type CleanupResult struct {
	// RemovedItems is the number of items removed
	RemovedItems int
	// RemovedBytes is the number of bytes freed
	RemovedBytes int64
	// RemovedStaleEntries is the number of stale map entries removed
	RemovedStaleEntries int
	// Duration is how long the cleanup took
	Duration time.Duration
}

CleanupResult holds the result of a cleanup operation

type ExtendedCache

type ExtendedCache interface {
	Cache
	// Stats returns current cache statistics
	Stats() CacheStats
	// Cleanup runs a manual cleanup cycle
	Cleanup() CleanupResult
	// Purge removes all cached items
	Purge() error
	// Close stops the cache and cleanup goroutines
	Close() error
}

ExtendedCache extends Cache with management capabilities

func NewDiskCacheWithConfig

func NewDiskCacheWithConfig(dir string, config *CacheConfig) (ExtendedCache, error)

NewDiskCacheWithConfig returns a disk-backed cache with custom configuration

func NewMemoryCacheWithConfig

func NewMemoryCacheWithConfig(config *CacheConfig) ExtendedCache

NewMemoryCacheWithConfig returns an ephemeral cache with custom configuration

func NewVFSCacheWithConfig

func NewVFSCacheWithConfig(fs vfs.VFS, config *CacheConfig) ExtendedCache

NewVFSCacheWithConfig returns a cache backend with custom configuration

type Handler

type Handler struct {
	// Shared reports whether this cache is shared between users (a reverse
	// proxy) rather than private to one (a browser-style cache).
	//
	// It defaults to false, which is the *private* cache profile: responses
	// marked "Cache-Control: private" and responses to requests carrying an
	// Authorization header are storable. Deploying a shared cache without
	// setting this serves one user's response to another. Prefer
	// NewSharedHandler, which cannot be forgotten.
	Shared bool
	// contains filtered or unexported fields
}

func NewHandler

func NewHandler(cache Cache, upstream http.Handler) *Handler

NewHandler returns a private cache handler with default options (package-level logger). Use NewSharedHandler for a cache that is shared between users, such as a reverse proxy.

func NewHandlerWithOptions

func NewHandlerWithOptions(cache Cache, upstream http.Handler, opts *HandlerOptions) *Handler

NewHandlerWithOptions returns a cache handler with the given options. If opts.Logger is set, it is used for handler logging; otherwise the package-level logger is used.

func NewSharedHandler

func NewSharedHandler(cache Cache, upstream http.Handler) *Handler

NewSharedHandler returns a cache handler configured as a shared cache: it refuses to store "Cache-Control: private" responses and responses to authorized requests unless they are explicitly marked public or carry s-maxage, and it strips headers listed in "private" before storing.

This is the constructor to use for a reverse proxy. NewHandler leaves Shared false, which is correct for a per-user cache and unsafe for a shared one.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request)

func (*Handler) SetMetrics

func (h *Handler) SetMetrics(m Metrics)

SetMetrics sets the recorder for this handler.

Passing nil installs NopMetrics rather than a nil interface, which would panic the first time the handler recorded anything.

func (*Handler) Shutdown

func (h *Handler) Shutdown(ctx context.Context) error

Shutdown prevents new background cache writes and waits for writes already in progress. The caller should invoke it before closing the cache backend.

type HandlerOptions

type HandlerOptions struct {
	Logger *logger.Logger

	// StaleMarkerTTL is how long the handler remembers an invalidation for a
	// Cache that does not implement StaleAt itself.
	//
	// It must be at least as long as that Cache retains entries: the marker is
	// the only record that entries older than it are pre-mutation, so expiring
	// it first republishes them as fresh. Only the implementation knows its
	// own retention, which is why this is a knob rather than a constant.
	//
	// Zero means DefaultCacheTTL. A Cache that implements StaleAt keeps its
	// own record and ignores this.
	StaleMarkerTTL time.Duration
}

HandlerOptions holds optional configuration for NewHandlerWithOptions. Logger injected here is used by the handler for all debug/error logging; if nil, the package-level logger (see SetLogger) is used.

type Header struct {
	http.Header
	StatusCode int
}

type Key

type Key struct {
	// contains filtered or unexported fields
}

Key represents a unique identifier for a resource in the cache

func NewKey

func NewKey(method string, u *url.URL, h http.Header) Key

NewKey returns a new Key instance

func NewRequestKey

func NewRequestKey(r *http.Request) Key

NewRequestKey generates a Key for a request.

The key is derived from the effective request URI only. A request header must never be able to choose which cache entry a response is stored under: letting the *request's* Content-Location pick the key allowed a client to park its own response under another URL's key ("GET /attacker-page" with "Content-Location: /admin"), poisoning a shared cache for every other user.

RFC 7234 does use Content-Location, but the *response's* — and only to invalidate entries, never to select a storage key. See invalidationKeys.

func (Key) ForMethod

func (k Key) ForMethod(method string) Key

ForMethod returns a new Key with a given method

func (Key) String

func (k Key) String() string

func (Key) Vary

func (k Key) Vary(varyHeader string, r *http.Request) Key

Vary returns a Key that is varied on particular headers in a http.Request

type Metrics

type Metrics interface {
	// RecordCacheHit records a cache hit for an HTTP method.
	RecordCacheHit(method string)
	// RecordCacheMiss records a cache miss for an HTTP method.
	RecordCacheMiss(method string)
	// RecordCacheSkip records a request that was not cacheable at all.
	RecordCacheSkip()
	// RecordUpstreamDuration records how long an upstream request took.
	RecordUpstreamDuration(method string, status int, durationSeconds float64)
	// RecordUpstreamError records an upstream failure by kind.
	RecordUpstreamError(errorType string)
	// RecordStoreOperation records an attempt to write an entry to the cache.
	RecordStoreOperation(success bool)
	// RecordRetrieveOperation records an attempt to read an entry from the cache.
	RecordRetrieveOperation(found bool)
	// SetCacheSize sets the current total size of cached bodies in bytes.
	SetCacheSize(sizeBytes int64)
	// SetCacheItemCount sets the current number of cached items.
	SetCacheItemCount(count int)
	// SetCacheStaleCount sets the current number of stale map entries.
	SetCacheStaleCount(count int)
	// RecordCacheEviction records one eviction, labelled by why it happened.
	RecordCacheEviction(reason string)
	// RecordCleanupDuration records how long a cleanup pass took.
	RecordCleanupDuration(durationSeconds float64)
	// UpdateCacheStats sets every gauge from a stats snapshot.
	UpdateCacheStats(stats CacheStats)
}

Metrics is what the cache records through. It is an interface, and the root package deliberately provides no implementation that talks to a metrics backend: the Prometheus one lives in the prometheusmetrics subpackage, so a service that does not export metrics never links Prometheus.

Implement it yourself to record into something else -- OpenTelemetry, statsd, a test double, an expvar map. Embed NopMetrics to pick up no-op implementations of the methods you do not care about; new methods added in a future minor release will then not break your type.

func GetDefaultMetrics

func GetDefaultMetrics() Metrics

GetDefaultMetrics returns the current recorder. It never returns nil: until something calls SetDefaultMetrics, it is a NopMetrics.

type NopMetrics

type NopMetrics struct{}

NopMetrics discards everything recorded through it. It is the default, so the cache never has to test for a missing recorder before recording -- and so no call site can panic on a nil one.

func (NopMetrics) RecordCacheEviction

func (NopMetrics) RecordCacheEviction(string)

func (NopMetrics) RecordCacheHit

func (NopMetrics) RecordCacheHit(string)

func (NopMetrics) RecordCacheMiss

func (NopMetrics) RecordCacheMiss(string)

func (NopMetrics) RecordCacheSkip

func (NopMetrics) RecordCacheSkip()

func (NopMetrics) RecordCleanupDuration

func (NopMetrics) RecordCleanupDuration(float64)

func (NopMetrics) RecordRetrieveOperation

func (NopMetrics) RecordRetrieveOperation(bool)

func (NopMetrics) RecordStoreOperation

func (NopMetrics) RecordStoreOperation(bool)

func (NopMetrics) RecordUpstreamDuration

func (NopMetrics) RecordUpstreamDuration(string, int, float64)

func (NopMetrics) RecordUpstreamError

func (NopMetrics) RecordUpstreamError(string)

func (NopMetrics) SetCacheItemCount

func (NopMetrics) SetCacheItemCount(int)

func (NopMetrics) SetCacheSize

func (NopMetrics) SetCacheSize(int64)

func (NopMetrics) SetCacheStaleCount

func (NopMetrics) SetCacheStaleCount(int)

func (NopMetrics) UpdateCacheStats

func (NopMetrics) UpdateCacheStats(CacheStats)

type ReadSeekCloser

type ReadSeekCloser interface {
	io.Reader
	io.Seeker
	io.Closer
}

type Resource

type Resource struct {
	ReadSeekCloser
	RequestTime, ResponseTime time.Time
	// contains filtered or unexported fields
}

func NewResource

func NewResource(statusCode int, body ReadSeekCloser, hdrs http.Header) *Resource

func NewResourceBytes

func NewResourceBytes(statusCode int, b []byte, hdrs http.Header) *Resource

func (*Resource) Age

func (r *Resource) Age() (time.Duration, error)

Calculate the age of the resource

func (*Resource) DateAfter

func (r *Resource) DateAfter(d time.Time) bool

func (*Resource) Expires

func (r *Resource) Expires() (time.Time, error)

func (*Resource) HasExplicitExpiration

func (r *Resource) HasExplicitExpiration() bool

func (*Resource) HasValidators

func (r *Resource) HasValidators() bool

func (*Resource) Header

func (r *Resource) Header() http.Header

func (*Resource) HeuristicFreshness

func (r *Resource) HeuristicFreshness() time.Duration

func (*Resource) IsNonErrorStatus

func (r *Resource) IsNonErrorStatus() bool

func (*Resource) IsStale

func (r *Resource) IsStale() bool

func (*Resource) LastModified

func (r *Resource) LastModified() time.Time

func (*Resource) MarkStale

func (r *Resource) MarkStale()

func (*Resource) MaxAge

func (r *Resource) MaxAge(shared bool) (time.Duration, error)

func (*Resource) MustValidate

func (r *Resource) MustValidate(shared bool) bool

func (*Resource) ReceivedAfter

func (r *Resource) ReceivedAfter(d time.Time) bool

func (*Resource) RemovePrivateHeaders

func (r *Resource) RemovePrivateHeaders()

func (*Resource) SetStoredAt

func (r *Resource) SetStoredAt(t time.Time)

ReceivedAfter reports whether this response reached the cache after d.

It reads Proxy-Date, which the handler stamps from the LOCAL clock the moment the upstream response arrives, and only falls back to the origin's Date for a response stored without one.

Deciding this from Date alone was wrong in both directions. A response that carries no Date at all -- which a direct http.Handler upstream may well omit -- never counted as superseding anything, so an invalidated key stayed stale on every retrieval and was refetched until the marker was swept. An origin whose clock trails the cache's did the same. Proxy-Date is always present and always this machine's clock.

Receive time, not store time: an older store still in flight when a mutation lands carries a Proxy-Date from BEFORE the invalidation, so the marker still wins and the pre-mutation body is not republished as fresh.

Both stamps are HTTP dates, so this is second-granular: a replacement that arrives in the same second as the invalidation does not count as superseding it and is refetched once more. That is the safe direction to round in. SetStoredAt records when this cache wrote the entry.

func (*Resource) Status

func (r *Resource) Status() int

func (*Resource) StoredAfter

func (r *Resource) StoredAfter(d time.Time) bool

StoredAfter reports whether this cache's copy is newer than d.

It prefers the cache's OWN store time to the response's dates, because Date and Proxy-Date are HTTP dates: one-second granularity, formatted with http.TimeFormat. An invalidation marker is a full-precision time.Time, so a response stored or revalidated in the same second as the invalidation is never "after" it -- and being judged against a marker it can never clear, the entry was re-marked stale and revalidated upstream on EVERY request until the marker was swept. The store time is also ours rather than the origin's, so a trailing origin clock cannot produce the same deadlock.

Falls back to the header dates for a resource that did not come from this cache, which is the only thing available for one.

func (*Resource) Via

func (r *Resource) Via() string

type Validator

type Validator struct {
	Handler http.Handler
}

func (*Validator) Validate

func (v *Validator) Validate(req *http.Request, res *Resource) bool

Directories

Path Synopsis
Package prometheusmetrics records httpcache activity into Prometheus.
Package prometheusmetrics records httpcache activity into Prometheus.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL