mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
fix(httpcache): back off after failed fetches
This commit is contained in:
parent
70aa1cbaa4
commit
71f7752015
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
@ -73,6 +78,7 @@ 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
|
||||||
|
FailureBackoff time.Duration // 0 follows MaxAge; <0 disables local failure backoff
|
||||||
MinInterval time.Duration // 0 disables; skip HTTP if last Fetch attempt was within this
|
MinInterval time.Duration // 0 disables; skip HTTP if last Fetch attempt was within this
|
||||||
MaxBytes int64 // 0 disables; cap on body bytes read per Fetch (defends against fill-disk)
|
MaxBytes int64 // 0 disables; cap on body bytes read per Fetch (defends against fill-disk)
|
||||||
Header http.Header // headers sent on every request
|
Header http.Header // headers sent on every request
|
||||||
@ -88,6 +94,8 @@ type Cacher struct {
|
|||||||
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
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user