A 14-upstream aggregate on bunny sent Maven Central over a thousand 429s an hour: 4463 upstream requests for 1336 distinct URLs, one missing .module walked through every upstream 42 times. Four things multiplied the traffic, and each is fixed here. A miss is remembered. When every upstream says 404 the answer is kept in memory for cache.not_found_ttl (default 10m, negative disables; capped at metadata_ttl for SNAPSHOT and metadata paths) and repeated without asking anyone. Only definite misses count: a throttled, unreachable or gateway-erroring upstream on the way means the 404 is relayed but not kept, so an outage is never cached as absence. A throttled upstream is benched. After a 429 it is skipped for every path for its Retry-After, clamped to 10s-5m (30s without one), shared by every vrepo that lists the same URL, egress and account, and only ever extended. While benched it still counts as rate limited, so a path no one else serves is a 429, not a 404. Skips are counted in tinyproxy_upstream_cooldown_skips_total. A HEAD miss is stored. Maven upstreams are asked with GET, the client gets the headers at once, and the body lands in the background under the key lock and a context detached from the request, at most 16 at a time; past that, and through cache: false upstreams, a HEAD stays a HEAD. Plain Serve (docker and the rest) is unchanged. Chained tiny-proxies read each other right. X-Tinyproxy-Answer marks an unsure 404 (uncertain), so the tier in front does not remember it, and a relayed 429 (relayed), so it does not bench a proxy that still serves what it has cached. A 404 from memory carries X-Tinyproxy-Miss-Ttl, and the tier in front remembers it no longer than that. Answers from memory are counted in tinyproxy_cache_remembered_misses_total, not as hits. When an upstream was throttled a trailing 401/403 is also reported as the 429 the build can retry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
633 lines
20 KiB
Go
633 lines
20 KiB
Go
// Package proxycache implements the shared read-through cache used by all
|
|
// proxy handlers (docker, maven, github, gitea, debian, pacman): serve from
|
|
// storage when fresh, otherwise fetch from the upstream, tee the body into
|
|
// storage, and stream to the client.
|
|
package proxycache
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"strconv"
|
|
"sync"
|
|
"time"
|
|
|
|
"tiny-proxy/internal/metrics"
|
|
"tiny-proxy/internal/storage"
|
|
)
|
|
|
|
// replayHeaders are response headers captured on store and replayed on hits.
|
|
var replayHeaders = []string{"Docker-Content-Digest", "Etag", "Last-Modified"}
|
|
|
|
type Cache struct {
|
|
Store storage.Store
|
|
// Metrics records the process-wide counters (resume outcomes). The
|
|
// per-vrepo cache counters come from the request context instead, since
|
|
// only the handler knows which repo a key belongs to. Nil is fine; the
|
|
// Inc methods are nil-receiver safe.
|
|
Metrics *metrics.Registry
|
|
|
|
mu sync.Mutex
|
|
locks map[string]*sync.Mutex
|
|
|
|
// misses remembers keys the upstreams definitively do not have, until
|
|
// the time stored for each. Memory only: it is short-lived by design,
|
|
// and losing it on restart costs one more upstream round each.
|
|
missMu sync.Mutex
|
|
misses map[string]time.Time
|
|
lastSweep time.Time
|
|
|
|
// headSlots bounds the background downloads HEAD misses start, so a
|
|
// client HEAD-ing hundreds of uncached artifacts cannot fan out that
|
|
// many full downloads; past the bound a HEAD is answered by a HEAD.
|
|
headSlots chan struct{}
|
|
}
|
|
|
|
// maxHeadStores is how many HEAD misses may be downloading into the store
|
|
// at once.
|
|
const maxHeadStores = 16
|
|
|
|
// MissTTLHeader carries, on a 404 answered from a remembered miss, the
|
|
// seconds that memory has left. A tiny-proxy chained in front remembers the
|
|
// 404 no longer than that, so misses do not stack up along a chain.
|
|
const MissTTLHeader = "X-Tinyproxy-Miss-Ttl"
|
|
|
|
// maxMisses caps the remembered misses, so a client probing random paths
|
|
// cannot grow the map without bound.
|
|
const maxMisses = 100_000
|
|
|
|
func New(store storage.Store) *Cache {
|
|
return &Cache{
|
|
Store: store,
|
|
locks: map[string]*sync.Mutex{},
|
|
misses: map[string]time.Time{},
|
|
headSlots: make(chan struct{}, maxHeadStores),
|
|
}
|
|
}
|
|
|
|
// missed reports whether key is a remembered miss that has not expired, and
|
|
// how long it has left.
|
|
func (c *Cache) missed(key string) (time.Duration, bool) {
|
|
c.missMu.Lock()
|
|
defer c.missMu.Unlock()
|
|
until, ok := c.misses[key]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
left := time.Until(until)
|
|
if left <= 0 {
|
|
delete(c.misses, key)
|
|
return 0, false
|
|
}
|
|
return left, true
|
|
}
|
|
|
|
// rememberMiss records that key is not found upstream, for ttl.
|
|
func (c *Cache) rememberMiss(key string, ttl time.Duration) {
|
|
c.missMu.Lock()
|
|
defer c.missMu.Unlock()
|
|
now := time.Now()
|
|
if len(c.misses) >= maxMisses {
|
|
// Sweep expired entries at most once a minute: a full map under a
|
|
// flood of new misses must not cost a 100k scan per insert.
|
|
if now.Sub(c.lastSweep) > time.Minute {
|
|
c.lastSweep = now
|
|
for k, until := range c.misses {
|
|
if now.After(until) {
|
|
delete(c.misses, k)
|
|
}
|
|
}
|
|
}
|
|
// Still full: make room by dropping an arbitrary entry, which at
|
|
// worst costs that path one more upstream walk.
|
|
for k := range c.misses {
|
|
if len(c.misses) < maxMisses {
|
|
break
|
|
}
|
|
delete(c.misses, k)
|
|
}
|
|
}
|
|
c.misses[key] = now.Add(ttl)
|
|
}
|
|
|
|
// keyLock serializes upstream fetches per cache key (single-flight).
|
|
func (c *Cache) keyLock(key string) *sync.Mutex {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
l, ok := c.locks[key]
|
|
if !ok {
|
|
l = &sync.Mutex{}
|
|
c.locks[key] = l
|
|
}
|
|
return l
|
|
}
|
|
|
|
// Result is what a fetch callback returns: the upstream response plus the
|
|
// caching policy for it. The zero NoStore caches as usual.
|
|
type Result struct {
|
|
Resp *http.Response
|
|
// NoStore streams this response to the client without writing it to
|
|
// storage. Handlers set it when the upstream that answered has
|
|
// cache: false — normally because that upstream is itself a caching
|
|
// layer, so storing the bytes here would just duplicate them.
|
|
NoStore bool
|
|
// Resume re-requests this object from the upstream that answered, with
|
|
// the Range/If-Range headers given, so a transfer that dies mid-body is
|
|
// continued instead of failing the client. Handlers build it with
|
|
// upstream.Resumer so the resume can only ever hit the same upstream
|
|
// and URL that served the first byte. Nil disables resuming.
|
|
Resume func(ctx context.Context, hdr http.Header) (*http.Response, error)
|
|
// ResumeAttempts caps how many times one transfer may be resumed, and
|
|
// ResumeMaxWait caps a single wait between those attempts. Handlers
|
|
// pass the answering upstream's retry budget.
|
|
ResumeAttempts int
|
|
ResumeMaxWait time.Duration
|
|
// NotFoundTTL, when positive, makes a 404 in Resp a remembered miss:
|
|
// for that long the key is answered 404 without calling fetch. Handlers
|
|
// set it only when every upstream really said "not found" — never when
|
|
// one was rate limited or down, or an outage would be cached as absence.
|
|
NotFoundTTL time.Duration
|
|
// BodyForHead says Resp answers a GET the handler sent although the
|
|
// client asked HEAD, so that a HEAD miss still lands in the cache: the
|
|
// body is stored and only the headers go to the client.
|
|
BodyForHead bool
|
|
}
|
|
|
|
// notFound answers a remembered miss that has left to go.
|
|
func notFound(w http.ResponseWriter, left time.Duration) {
|
|
w.Header().Set(MissTTLHeader, strconv.Itoa(int((left+time.Second-1)/time.Second)))
|
|
http.Error(w, "not found", http.StatusNotFound)
|
|
}
|
|
|
|
// headStoreKey marks the context of a HEAD fetch that may answer with a GET
|
|
// to be stored (see HeadStorable).
|
|
type headStoreKey struct{}
|
|
|
|
// HeadStorable reports whether the fetch running under ctx serves a HEAD
|
|
// that may be answered by a GET whose body is stored in the background
|
|
// (Result.BodyForHead). When false — a plain Serve, or too many such
|
|
// downloads already running — the fetch should send the HEAD as is.
|
|
func HeadStorable(ctx context.Context) bool {
|
|
ok, _ := ctx.Value(headStoreKey{}).(bool)
|
|
return ok
|
|
}
|
|
|
|
// ServeStoringHead is Serve for handlers whose fetch may answer a HEAD miss
|
|
// with a GET to be stored (see HeadStorable and Result.BodyForHead), so a
|
|
// HEAD does not reach the upstreams again and again. Such a fetch runs
|
|
// detached from the request, since its download outlives it.
|
|
func (c *Cache) ServeStoringHead(w http.ResponseWriter, r *http.Request, key string, ttl time.Duration, fetch func(ctx context.Context) (Result, error)) {
|
|
c.serve(w, r, key, ttl, fetch, true)
|
|
}
|
|
|
|
// Serve answers r from the cache, fetching from upstream on a miss.
|
|
// fetch performs the upstream request with the same method as r and returns
|
|
// its response; ttl controls how long the cached object counts as fresh
|
|
// (0 = immutable). Non-2xx upstream responses, and responses the fetch
|
|
// marked NoStore, are proxied without caching.
|
|
func (c *Cache) Serve(w http.ResponseWriter, r *http.Request, key string, ttl time.Duration, fetch func(ctx context.Context) (Result, error)) {
|
|
c.serve(w, r, key, ttl, fetch, false)
|
|
}
|
|
|
|
func (c *Cache) serve(w http.ResponseWriter, r *http.Request, key string, ttl time.Duration, fetch func(ctx context.Context) (Result, error), storeHead bool) {
|
|
// The vrepo serving this request was recorded by its handler, so the
|
|
// cache outcome lands on that repo's series rather than one global
|
|
// hit rate that averages a warm mirror with a cold one.
|
|
stats := metrics.VRepoFromContext(r.Context())
|
|
if c.serveHit(w, r, key, ttl) {
|
|
stats.IncHit()
|
|
return
|
|
}
|
|
if left, ok := c.missed(key); ok {
|
|
c.Metrics.IncRememberedMiss()
|
|
notFound(w, left)
|
|
return
|
|
}
|
|
|
|
lock := c.keyLock(key)
|
|
lock.Lock()
|
|
// A HEAD miss may be answered by storing a GET in the background, which
|
|
// then owns the lock and the context until the body has landed.
|
|
background := false
|
|
defer func() {
|
|
if !background {
|
|
lock.Unlock()
|
|
}
|
|
}()
|
|
// Double-check: another request may have populated the key — or found
|
|
// it missing everywhere — while we waited.
|
|
if c.serveHit(w, r, key, ttl) {
|
|
stats.IncHit()
|
|
return
|
|
}
|
|
if left, ok := c.missed(key); ok {
|
|
c.Metrics.IncRememberedMiss()
|
|
notFound(w, left)
|
|
return
|
|
}
|
|
|
|
stats.IncMiss()
|
|
// A HEAD whose fetch may turn into a download to store (BodyForHead)
|
|
// takes a slot, and runs detached from the request: the client is done
|
|
// the moment it has the headers, and the download outlives it.
|
|
ctx, cancel := r.Context(), context.CancelFunc(func() {})
|
|
slot := false
|
|
if storeHead && r.Method == http.MethodHead {
|
|
select {
|
|
case c.headSlots <- struct{}{}:
|
|
slot = true
|
|
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), headStoreTimeout)
|
|
ctx = context.WithValue(ctx, headStoreKey{}, true)
|
|
default:
|
|
}
|
|
}
|
|
release := func() {
|
|
cancel()
|
|
if slot {
|
|
<-c.headSlots
|
|
}
|
|
}
|
|
defer func() {
|
|
if !background {
|
|
release()
|
|
}
|
|
}()
|
|
res, err := fetch(ctx)
|
|
if err == nil && res.Resp == nil {
|
|
err = errors.New("fetch returned no response")
|
|
}
|
|
if err != nil {
|
|
stats.IncError()
|
|
slog.ErrorContext(ctx, "upstream fetch failed", "key", key, "err", err)
|
|
http.Error(w, "upstream error", http.StatusBadGateway)
|
|
return
|
|
}
|
|
resp := res.Resp
|
|
|
|
if resp.StatusCode == http.StatusNotFound && res.NotFoundTTL > 0 {
|
|
c.rememberMiss(key, res.NotFoundTTL)
|
|
}
|
|
if resp.StatusCode == http.StatusOK && !res.NoStore {
|
|
switch {
|
|
case r.Method == http.MethodGet:
|
|
defer resp.Body.Close()
|
|
c.serveAndStore(w, r, key, res)
|
|
return
|
|
case r.Method == http.MethodHead && res.BodyForHead && slot:
|
|
// The client gets its headers and its connection back now;
|
|
// the body goes to storage behind it, still under the key
|
|
// lock so a request for the same key waits for it.
|
|
meta := metaFromResponse(resp)
|
|
writeMetaHeaders(w, &meta)
|
|
background = true
|
|
go func() {
|
|
defer lock.Unlock()
|
|
defer release()
|
|
defer resp.Body.Close()
|
|
// Out here no handler recovers a panic: it would take
|
|
// the whole process down, not one request.
|
|
defer func() {
|
|
if p := recover(); p != nil {
|
|
slog.ErrorContext(ctx, "background store panicked", "key", key, "panic", p)
|
|
}
|
|
}()
|
|
c.store(ctx, key, res, nil)
|
|
}()
|
|
return
|
|
}
|
|
}
|
|
defer resp.Body.Close()
|
|
proxyResponse(w, r, resp)
|
|
}
|
|
|
|
// headStoreTimeout bounds the background download a HEAD miss starts.
|
|
const headStoreTimeout = 10 * time.Minute
|
|
|
|
func (c *Cache) serveHit(w http.ResponseWriter, r *http.Request, key string, ttl time.Duration) bool {
|
|
meta, err := c.Store.Stat(r.Context(), key)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
if meta.Expired(ttl) {
|
|
return false
|
|
}
|
|
rc, meta, err := c.Store.Get(r.Context(), key)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer rc.Close()
|
|
writeMetaHeaders(w, meta)
|
|
if r.Method == http.MethodHead {
|
|
return true
|
|
}
|
|
if _, err := io.Copy(w, rc); err != nil {
|
|
slog.WarnContext(r.Context(), "stream from cache failed", "key", key, "err", err)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// serveAndStore streams a 200 OK upstream body to the client while tee-ing
|
|
// it into storage.
|
|
//
|
|
// The cache write is committed only when the whole object arrived. A
|
|
// transfer that dies mid-body — a reset connection, a truncated TLS stream, a
|
|
// server that closes early — aborts the pipe with an error so the store
|
|
// discards its temp file. Committing it instead is what poisons a cache
|
|
// permanently: immutable artifacts never expire, so a truncated .deb would be
|
|
// served as the real thing forever.
|
|
func (c *Cache) serveAndStore(w http.ResponseWriter, r *http.Request, key string, res Result) {
|
|
meta := metaFromResponse(res.Resp)
|
|
writeMetaHeaders(w, &meta)
|
|
c.store(r.Context(), key, res, w)
|
|
}
|
|
|
|
// store tees a 200 OK upstream body into storage and, when client is not
|
|
// nil, to the client. With no client, it stops as soon as the store fails:
|
|
// there is nobody left to read the rest.
|
|
func (c *Cache) store(ctx context.Context, key string, res Result, client io.Writer) {
|
|
resp := res.Resp
|
|
meta := metaFromResponse(resp)
|
|
|
|
body := c.upstreamBody(ctx, key, res)
|
|
defer body.Close()
|
|
|
|
pr, pw := io.Pipe()
|
|
putDone := make(chan error, 1)
|
|
go func() {
|
|
// Size 0 so the store records the bytes that actually landed
|
|
// rather than the length the upstream advertised: the two disagree
|
|
// exactly when something went wrong, and a sidecar claiming more
|
|
// than the file holds makes every later hit fail.
|
|
putMeta := meta
|
|
putMeta.Size = 0
|
|
err := c.Store.Put(ctx, key, pr, putMeta)
|
|
if err != nil {
|
|
// Unblock the tee: if Put fails before draining the pipe
|
|
// (e.g. unwritable storage), the pipe writer would block
|
|
// forever and the client would hang.
|
|
pr.CloseWithError(err)
|
|
}
|
|
putDone <- err
|
|
}()
|
|
|
|
// Hand-rolled tee: read upstream, write to the cache pipe (until it
|
|
// fails — caching is best-effort) and always to the client.
|
|
// io.TeeReader would drop a chunk on pipe error because it consumes
|
|
// bytes from the upstream before the failing write reports n=0.
|
|
buf := make([]byte, 32*1024)
|
|
var written int64
|
|
var copyErr, failErr error
|
|
cacheLive := true
|
|
abort := func(err error) {
|
|
if cacheLive {
|
|
cacheLive = false
|
|
pw.CloseWithError(err)
|
|
}
|
|
}
|
|
for client != nil || cacheLive {
|
|
n, readErr := body.Read(buf)
|
|
if n > 0 {
|
|
chunk := buf[:n]
|
|
if cacheLive {
|
|
if err := writeAll(pw, chunk); err != nil {
|
|
abort(err)
|
|
}
|
|
}
|
|
if client != nil {
|
|
if err := writeAll(client, chunk); err != nil {
|
|
copyErr = err
|
|
abort(err)
|
|
break
|
|
}
|
|
}
|
|
written += int64(n)
|
|
}
|
|
if readErr != nil {
|
|
if !errors.Is(readErr, io.EOF) {
|
|
failErr = readErr
|
|
}
|
|
break
|
|
}
|
|
}
|
|
// A clean close that still delivered fewer bytes than advertised is a
|
|
// failed transfer too, and the one the client is most likely to mistake
|
|
// for a good file.
|
|
if failErr == nil && copyErr == nil && (client != nil || cacheLive) && resp.ContentLength >= 0 && written != resp.ContentLength {
|
|
failErr = fmt.Errorf("upstream sent %d bytes, declared %d", written, resp.ContentLength)
|
|
}
|
|
if failErr != nil {
|
|
abort(failErr)
|
|
}
|
|
if cacheLive {
|
|
pw.Close()
|
|
}
|
|
|
|
putErr := <-putDone
|
|
stats := metrics.VRepoFromContext(ctx)
|
|
switch {
|
|
case failErr != nil:
|
|
stats.IncError()
|
|
stats.IncTransferFailure()
|
|
slog.WarnContext(ctx, "upstream transfer failed, not caching",
|
|
"key", key, "written", written, "declared", resp.ContentLength, "err", failErr)
|
|
case putErr != nil:
|
|
stats.IncError()
|
|
slog.ErrorContext(ctx, "cache store failed", "key", key, "err", putErr)
|
|
}
|
|
if copyErr != nil {
|
|
slog.WarnContext(ctx, "stream to client failed", "key", key, "err", copyErr)
|
|
}
|
|
}
|
|
|
|
// upstreamBody returns the reader serveAndStore should tee from: the raw
|
|
// body, or one that transparently resumes when the transfer drops, if the
|
|
// handler supplied a way to re-request from the upstream that answered.
|
|
func (c *Cache) upstreamBody(ctx context.Context, key string, res Result) io.ReadCloser {
|
|
if res.Resume == nil || res.ResumeAttempts <= 0 {
|
|
return res.Resp.Body
|
|
}
|
|
return &resumeReader{
|
|
body: res.Resp.Body,
|
|
resume: res.Resume,
|
|
ctx: ctx,
|
|
ifRange: validator(res.Resp),
|
|
attempts: res.ResumeAttempts,
|
|
maxWait: res.ResumeMaxWait,
|
|
key: key,
|
|
metrics: c.Metrics,
|
|
}
|
|
}
|
|
|
|
// writeAll writes the whole chunk, treating a short write as an error.
|
|
// io.Writer permits n < len(p) with a nil error; silently dropping the
|
|
// remainder would put a hole in a cached object.
|
|
func writeAll(w io.Writer, p []byte) error {
|
|
n, err := w.Write(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n != len(p) {
|
|
return io.ErrShortWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// proxyResponse copies an upstream response to the client unchanged
|
|
// (used for non-200 statuses, HEAD misses and partial responses).
|
|
func proxyResponse(w http.ResponseWriter, r *http.Request, resp *http.Response) {
|
|
copyHeader(w.Header(), resp.Header)
|
|
w.WriteHeader(resp.StatusCode)
|
|
if r.Method != http.MethodHead {
|
|
io.Copy(w, resp.Body)
|
|
}
|
|
}
|
|
|
|
func metaFromResponse(resp *http.Response) storage.ObjectMeta {
|
|
meta := storage.ObjectMeta{
|
|
ContentType: resp.Header.Get("Content-Type"),
|
|
Size: resp.ContentLength,
|
|
StoredAt: time.Now(),
|
|
Headers: map[string]string{},
|
|
}
|
|
// Chunked responses report -1; leave 0 so the store records the
|
|
// actual byte count after the tee.
|
|
if meta.Size < 0 {
|
|
meta.Size = 0
|
|
}
|
|
for _, h := range replayHeaders {
|
|
if v := resp.Header.Get(h); v != "" {
|
|
meta.Headers[h] = v
|
|
}
|
|
}
|
|
return meta
|
|
}
|
|
|
|
func writeMetaHeaders(w http.ResponseWriter, meta *storage.ObjectMeta) {
|
|
h := w.Header()
|
|
if meta.ContentType != "" {
|
|
h.Set("Content-Type", meta.ContentType)
|
|
}
|
|
if meta.Size > 0 {
|
|
h.Set("Content-Length", strconv.FormatInt(meta.Size, 10))
|
|
}
|
|
for k, v := range meta.Headers {
|
|
h.Set(k, v)
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
|
|
func copyHeader(dst, src http.Header) {
|
|
for k, vs := range src {
|
|
for _, v := range vs {
|
|
dst.Add(k, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// IsNotFound reports whether err is a storage cache miss.
|
|
func IsNotFound(err error) bool {
|
|
return errors.Is(err, storage.ErrNotFound)
|
|
}
|
|
|
|
// Fetch returns the cached body for key, fetching from upstream on a miss or
|
|
// when the cached object is older than ttl (0 = immutable). It is the
|
|
// in-process counterpart of Serve, for values a handler needs to inspect
|
|
// (e.g. alias resolution) rather than stream to a client. Only 200 OK
|
|
// bodies are cached, and only when the fetch did not mark them NoStore;
|
|
// other statuses return an *UpstreamError.
|
|
func (c *Cache) Fetch(ctx context.Context, key string, ttl time.Duration, fetch func(ctx context.Context) (Result, error)) ([]byte, error) {
|
|
stats := metrics.VRepoFromContext(ctx)
|
|
if body, ok := c.fetchHit(ctx, key, ttl); ok {
|
|
stats.IncHit()
|
|
return body, nil
|
|
}
|
|
if _, ok := c.missed(key); ok {
|
|
c.Metrics.IncRememberedMiss()
|
|
return nil, &UpstreamError{StatusCode: http.StatusNotFound}
|
|
}
|
|
|
|
lock := c.keyLock(key)
|
|
lock.Lock()
|
|
defer lock.Unlock()
|
|
// Double-check: another goroutine may have populated the key, or found
|
|
// it missing.
|
|
if body, ok := c.fetchHit(ctx, key, ttl); ok {
|
|
stats.IncHit()
|
|
return body, nil
|
|
}
|
|
if _, ok := c.missed(key); ok {
|
|
c.Metrics.IncRememberedMiss()
|
|
return nil, &UpstreamError{StatusCode: http.StatusNotFound}
|
|
}
|
|
|
|
stats.IncMiss()
|
|
res, err := fetch(ctx)
|
|
if err == nil && res.Resp == nil {
|
|
err = errors.New("fetch returned no response")
|
|
}
|
|
if err != nil {
|
|
stats.IncError()
|
|
return nil, err
|
|
}
|
|
resp := res.Resp
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
if resp.StatusCode == http.StatusNotFound && res.NotFoundTTL > 0 {
|
|
c.rememberMiss(key, res.NotFoundTTL)
|
|
}
|
|
return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: body}
|
|
}
|
|
// io.ReadAll already reports a torn connection; this catches the
|
|
// quieter case of a clean close after a short body.
|
|
if resp.ContentLength >= 0 && int64(len(body)) != resp.ContentLength {
|
|
stats.IncError()
|
|
stats.IncTransferFailure()
|
|
return nil, fmt.Errorf("upstream sent %d bytes, declared %d", len(body), resp.ContentLength)
|
|
}
|
|
if res.NoStore {
|
|
return body, nil
|
|
}
|
|
meta := metaFromResponse(resp)
|
|
meta.Size = int64(len(body))
|
|
if err := c.Store.Put(ctx, key, bytes.NewReader(body), meta); err != nil {
|
|
stats.IncError()
|
|
slog.ErrorContext(ctx, "cache store failed", "key", key, "err", err)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func (c *Cache) fetchHit(ctx context.Context, key string, ttl time.Duration) ([]byte, bool) {
|
|
meta, err := c.Store.Stat(ctx, key)
|
|
if err != nil || meta.Expired(ttl) {
|
|
return nil, false
|
|
}
|
|
rc, _, err := c.Store.Get(ctx, key)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer rc.Close()
|
|
body, err := io.ReadAll(rc)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
// UpstreamError reports a non-200 upstream response from Fetch.
|
|
type UpstreamError struct {
|
|
StatusCode int
|
|
Body []byte
|
|
}
|
|
|
|
func (e *UpstreamError) Error() string {
|
|
return fmt.Sprintf("upstream status %d", e.StatusCode)
|
|
}
|