fix(httpcache): back off after failed fetches

This commit is contained in:
AJ ONeal 2026-09-08 14:44:48 -06:00
parent 70aa1cbaa4
commit 71f7752015
No known key found for this signature in database
3 changed files with 147 additions and 22 deletions

View File

@ -4,6 +4,8 @@ Conditional HTTP GET to a local file. ETag / Last-Modified are persisted to a
`<path>.meta` sidecar so 304s survive process restarts. `<path>.meta` sidecar so 304s survive process restarts.
- skips HTTP when the local file is newer than `MaxAge` - skips HTTP when the local file is newer than `MaxAge`
- backs off failed fetches for `FailureBackoff` (defaults to `MaxAge`)
- honors a valid server `Retry-After` response over local backoff
- skips HTTP when the last attempt was within `MinInterval` (in-memory) - skips HTTP when the last attempt was within `MinInterval` (in-memory)
- caps response bodies via `MaxBytes` (defends against fill-disk) - caps response bodies via `MaxBytes` (defends against fill-disk)
- `O_EXCL` reservation on `<path>.tmp` for cross-process exclusion; - `O_EXCL` reservation on `<path>.tmp` for cross-process exclusion;
@ -20,6 +22,7 @@ c := httpcache.New(
"/srv/data/blocklist.txt", "/srv/data/blocklist.txt",
) )
c.MaxAge = time.Hour // skip HTTP if file is < 1h old c.MaxAge = time.Hour // skip HTTP if file is < 1h old
// c.FailureBackoff = 30 * time.Minute // defaults to MaxAge
c.MaxBytes = 50 << 20 // 50 MiB cap c.MaxBytes = 50 << 20 // 50 MiB cap
updated, err := c.Fetch(ctx) updated, err := c.Fetch(ctx)

View File

@ -12,6 +12,8 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
"time" "time"
"golang.org/x/sync/singleflight" "golang.org/x/sync/singleflight"
@ -53,11 +55,14 @@ const (
// - MaxAge: skips if the local file's mtime is within this duration. // - MaxAge: skips if the local file's mtime is within this duration.
// Useful when the remote preserves meaningful timestamps (e.g. MaxMind // Useful when the remote preserves meaningful timestamps (e.g. MaxMind
// encodes the database release date as the tar entry mtime). // encodes the database release date as the tar entry mtime).
// - FailureBackoff: skips if the last fetch failure was within this duration.
// 0 follows MaxAge; negative disables local failure backoff. A valid
// Retry-After response always takes precedence.
// - MinInterval: skips if Fetch was called within this duration (in-memory). // - MinInterval: skips if Fetch was called within this duration (in-memory).
// Guards against tight poll loops hammering a rate-limited API. // Guards against tight poll loops hammering a rate-limited API.
// //
// Caching — ETag and Last-Modified values are persisted to a <path>.meta // Caching — ETag, Last-Modified, and failure retry times are persisted to a
// sidecar file so conditional GETs survive process restarts. // <path>.meta sidecar file so conditional GETs and backoff survive restarts.
// //
// Header — any values in Header are sent on every request. The stdlib // Header — any values in Header are sent on every request. The stdlib
// http.Client strips Authorization, WWW-Authenticate, and Cookie on // http.Client strips Authorization, WWW-Authenticate, and Cookie on
@ -70,12 +75,13 @@ const (
// disk fills, since the overall Timeout still allows multi-GB transfers. // disk fills, since the overall Timeout still allows multi-GB transfers.
// 0 disables the cap. // 0 disables the cap.
type Cacher struct { type Cacher struct {
URL string URL string
Path string Path string
MaxAge time.Duration // 0 disables; skip HTTP if file mtime is within this MaxAge time.Duration // 0 disables; skip HTTP if file mtime is within this
MinInterval time.Duration // 0 disables; skip HTTP if last Fetch attempt was within this FailureBackoff time.Duration // 0 follows MaxAge; <0 disables local failure backoff
MaxBytes int64 // 0 disables; cap on body bytes read per Fetch (defends against fill-disk) MinInterval time.Duration // 0 disables; skip HTTP if last Fetch attempt was within this
Header http.Header // headers sent on every request MaxBytes int64 // 0 disables; cap on body bytes read per Fetch (defends against fill-disk)
Header http.Header // headers sent on every request
sf singleflight.Group sf singleflight.Group
cacheMeta // embedded: etag/lastMod persisted to sidecar cacheMeta // embedded: etag/lastMod persisted to sidecar
@ -86,8 +92,10 @@ type Cacher struct {
// cacheMeta is the sidecar format persisted alongside the downloaded file. // cacheMeta is the sidecar format persisted alongside the downloaded file.
type cacheMeta struct { type cacheMeta struct {
ETag string `json:"etag,omitempty"` ETag string `json:"etag,omitempty"`
LastMod string `json:"last_modified,omitempty"` LastMod string `json:"last_modified,omitempty"`
LastFailure *time.Time `json:"last_failure,omitempty"`
RetryAt *time.Time `json:"retry_at,omitempty"`
} }
func (c *Cacher) metaPath() string { return c.Path + ".meta" } func (c *Cacher) metaPath() string { return c.Path + ".meta" }
@ -165,21 +173,12 @@ func NewWith(url, path string, client *http.Client) *Cacher {
// Fetch sends a conditional GET and writes new content to Path if the server // Fetch sends a conditional GET and writes new content to Path if the server
// responds with 200. Returns whether the file was updated. // responds with 200. Returns whether the file was updated.
// //
// Both MaxAge and MinInterval are checked before making any HTTP request. // MaxAge, FailureBackoff, and MinInterval are checked before making any HTTP request.
// ctx cancels the in-flight request and any blocking body read. // ctx cancels the in-flight request and any blocking body read.
// //
// Safe to call concurrently — concurrent callers share a single in-flight // Safe to call concurrently — concurrent callers share a single in-flight
// fetch (via singleflight) and all receive the same result. // fetch (via singleflight) and all receive the same result.
func (c *Cacher) Fetch(ctx context.Context) (updated bool, err error) { func (c *Cacher) Fetch(ctx context.Context) (updated bool, err error) {
// MaxAge: file-mtime gate (no lock needed — just a stat).
if c.MaxAge > 0 {
if info, err := os.Stat(c.Path); err == nil {
if time.Since(info.ModTime()) < c.MaxAge {
return false, nil
}
}
}
type result struct { type result struct {
updated bool updated bool
err error err error
@ -194,8 +193,9 @@ func (c *Cacher) Fetch(ctx context.Context) (updated bool, err error) {
// fetch is the inner serialized work. singleflight ensures only one runs at // fetch is the inner serialized work. singleflight ensures only one runs at
// a time, so the cacheMeta/lastChecked/metaLoaded fields don't need a mutex. // a time, so the cacheMeta/lastChecked/metaLoaded fields don't need a mutex.
func (c *Cacher) fetch(ctx context.Context) (bool, error) { func (c *Cacher) fetch(ctx context.Context) (updated bool, err error) {
// Load sidecar once so conditional GETs work after a process restart. // Load sidecar once so conditional GETs and failure backoff work after a
// process restart.
if !c.metaLoaded { if !c.metaLoaded {
if err := c.loadMeta(); err != nil { if err := c.loadMeta(); err != nil {
return false, err return false, err
@ -203,6 +203,28 @@ func (c *Cacher) fetch(ctx context.Context) (bool, error) {
c.metaLoaded = true c.metaLoaded = true
} }
// MaxAge: file-mtime gate.
if c.MaxAge > 0 {
if info, statErr := os.Stat(c.Path); statErr == nil && time.Since(info.ModTime()) < c.MaxAge {
return false, nil
}
}
// FailureBackoff: persisted failure gate. This prevents a stale file from
// causing a tight retry loop against a rate-limited server.
if c.RetryAt != nil {
if time.Now().Before(*c.RetryAt) {
return false, nil
}
} else {
failureBackoff := c.FailureBackoff
if failureBackoff == 0 {
failureBackoff = c.MaxAge
}
if failureBackoff > 0 && c.LastFailure != nil && time.Since(*c.LastFailure) < failureBackoff {
return false, nil
}
}
// MinInterval: in-memory last-checked gate. // MinInterval: in-memory last-checked gate.
if c.MinInterval > 0 && !c.lastChecked.IsZero() { if c.MinInterval > 0 && !c.lastChecked.IsZero() {
if time.Since(c.lastChecked) < c.MinInterval { if time.Since(c.lastChecked) < c.MinInterval {
@ -210,6 +232,12 @@ func (c *Cacher) fetch(ctx context.Context) (bool, error) {
} }
} }
c.lastChecked = time.Now() c.lastChecked = time.Now()
var retryAt *time.Time
defer func() {
if err != nil {
err = c.recordFailure(err, retryAt)
}
}()
// Reserve .tmp before any HTTP — O_EXCL gives us cross-process // Reserve .tmp before any HTTP — O_EXCL gives us cross-process
// exclusion (singleflight only covers the in-process case). A peer // exclusion (singleflight only covers the in-process case). A peer
@ -264,9 +292,15 @@ func (c *Cacher) fetch(ctx context.Context) (bool, error) {
defer func() { _ = resp.Body.Close() }() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotModified { if resp.StatusCode == http.StatusNotModified {
c.LastFailure = nil
c.RetryAt = nil
if err := c.saveMeta(); err != nil {
return false, fmt.Errorf("%w for %s: %w", ErrSaveMeta, c.Path, err)
}
return false, nil return false, nil
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
retryAt = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
return false, fmt.Errorf("%w %d fetching %s", ErrUnexpectedStatus, resp.StatusCode, c.safeURL()) return false, fmt.Errorf("%w %d fetching %s", ErrUnexpectedStatus, resp.StatusCode, c.safeURL())
} }
@ -295,9 +329,36 @@ func (c *Cacher) fetch(ctx context.Context) (bool, error) {
if lm := resp.Header.Get("Last-Modified"); lm != "" { if lm := resp.Header.Get("Last-Modified"); lm != "" {
c.LastMod = lm c.LastMod = lm
} }
c.LastFailure = nil
c.RetryAt = nil
if err := c.saveMeta(); err != nil { if err := c.saveMeta(); err != nil {
return true, fmt.Errorf("%w for %s: %w", ErrSaveMeta, c.Path, err) return true, fmt.Errorf("%w for %s: %w", ErrSaveMeta, c.Path, err)
} }
return true, nil return true, nil
} }
func (c *Cacher) recordFailure(err error, retryAt *time.Time) error {
now := time.Now()
c.LastFailure = &now
c.RetryAt = retryAt
if saveErr := c.saveMeta(); saveErr != nil {
return errors.Join(err, fmt.Errorf("%w for %s: %w", ErrSaveMeta, c.Path, saveErr))
}
return err
}
func parseRetryAfter(value string, now time.Time) *time.Time {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
if seconds, err := strconv.ParseInt(value, 10, 64); err == nil && seconds >= 0 {
t := now.Add(time.Duration(seconds) * time.Second)
return &t
}
if t, err := http.ParseTime(value); err == nil {
return &t
}
return nil
}

View File

@ -9,6 +9,7 @@ import (
"path/filepath" "path/filepath"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time"
"github.com/therootcompany/golib/net/httpcache" "github.com/therootcompany/golib/net/httpcache"
) )
@ -154,6 +155,66 @@ func TestCacher_EmptyResponse(t *testing.T) {
} }
} }
func TestCacher_MaxAgeBacksOffAfterFailure(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
http.Error(w, "rate limited", http.StatusTooManyRequests)
}))
defer srv.Close()
path := filepath.Join(t.TempDir(), "data.txt")
c := httpcache.New(srv.URL, path)
c.MaxAge = time.Hour
if _, err := c.Fetch(t.Context()); !errors.Is(err, httpcache.ErrUnexpectedStatus) {
t.Fatalf("first Fetch: %v, want ErrUnexpectedStatus", err)
}
// The failed attempt is persisted, so a new process also waits instead
// of retrying immediately against the rate-limited server.
fresh := httpcache.New(srv.URL, path)
fresh.MaxAge = time.Hour
updated, err := fresh.Fetch(t.Context())
if err != nil {
t.Fatalf("backoff Fetch: %v", err)
}
if updated {
t.Error("backoff Fetch: updated=true, want false")
}
if got := hits.Load(); got != 1 {
t.Errorf("server hits = %d, want 1", got)
}
}
func TestCacher_RetryAfterOverridesFailureBackoff(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Header().Set("Retry-After", "3600")
http.Error(w, "rate limited", http.StatusTooManyRequests)
}))
defer srv.Close()
path := filepath.Join(t.TempDir(), "data.txt")
c := httpcache.New(srv.URL, path)
c.FailureBackoff = time.Second
if _, err := c.Fetch(t.Context()); !errors.Is(err, httpcache.ErrUnexpectedStatus) {
t.Fatalf("first Fetch: %v, want ErrUnexpectedStatus", err)
}
fresh := httpcache.New(srv.URL, path)
updated, err := fresh.Fetch(t.Context())
if err != nil {
t.Fatalf("Retry-After Fetch: %v", err)
}
if updated {
t.Error("Retry-After Fetch: updated=true, want false")
}
if got := hits.Load(); got != 1 {
t.Errorf("server hits = %d, want 1", got)
}
}
func TestCacher_MaxBytesExceeded(t *testing.T) { func TestCacher_MaxBytesExceeded(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("0123456789ABCDEF")) // 16 bytes _, _ = w.Write([]byte("0123456789ABCDEF")) // 16 bytes