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>
513 lines
16 KiB
Go
513 lines
16 KiB
Go
package proxycache
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"tiny-proxy/internal/metrics"
|
|
"tiny-proxy/internal/storage"
|
|
)
|
|
|
|
// markVRepo binds req to a vrepo's stats, standing in for the
|
|
// metrics.MarkVRepo a real handler makes once it has resolved the repo.
|
|
func markVRepo(req *http.Request, stats []*metrics.VRepoStats) *http.Request {
|
|
if len(stats) == 0 {
|
|
return req
|
|
}
|
|
return req.WithContext(metrics.WithVRepo(req.Context(), stats[0]))
|
|
}
|
|
|
|
// testVRepo registers one vrepo and returns its stats.
|
|
func testVRepo() *metrics.VRepoStats {
|
|
return metrics.NewRegistry().RegisterVRepo("v", "maven")
|
|
}
|
|
|
|
func testCache(t *testing.T) *Cache {
|
|
t.Helper()
|
|
store, err := storage.NewLocalFS(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return New(store)
|
|
}
|
|
|
|
// get serves key through the cache. The optional stats binds the request to a
|
|
// vrepo the way a handler's MarkVRepo does, so the cache counters have
|
|
// somewhere to land; tests that do not look at metrics pass nothing.
|
|
func get(t *testing.T, c *Cache, upstream http.Handler, key string, ttl time.Duration, stats ...*metrics.VRepoStats) (int, string) {
|
|
t.Helper()
|
|
return getPolicy(t, c, upstream, key, ttl, false, stats...)
|
|
}
|
|
|
|
// getPolicy is get with an explicit NoStore policy on the fetch result.
|
|
func getPolicy(t *testing.T, c *Cache, upstream http.Handler, key string, ttl time.Duration, noStore bool, stats ...*metrics.VRepoStats) (int, string) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
req := markVRepo(httptest.NewRequest(http.MethodGet, "/", nil), stats)
|
|
c.Serve(rec, req, key, ttl, func(ctx context.Context) (Result, error) {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
|
|
rec2 := httptest.NewRecorder()
|
|
upstream.ServeHTTP(rec2, req)
|
|
return Result{Resp: rec2.Result(), NoStore: noStore}, nil
|
|
})
|
|
res := rec.Result()
|
|
body, _ := io.ReadAll(res.Body)
|
|
return res.StatusCode, string(body)
|
|
}
|
|
|
|
func TestMissThenHit(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Content-Type", "text/plain")
|
|
fmt.Fprint(w, "payload")
|
|
})
|
|
c := testCache(t)
|
|
|
|
code, body := get(t, c, upstream, "k", 0)
|
|
if code != 200 || body != "payload" {
|
|
t.Fatalf("miss: code=%d body=%q", code, body)
|
|
}
|
|
code, body = get(t, c, upstream, "k", 0)
|
|
if code != 200 || body != "payload" {
|
|
t.Fatalf("hit: code=%d body=%q", code, body)
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Errorf("upstream hits = %d, want 1 (second request should be cached)", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestTTLRefetch(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, "v%d", hits.Add(1))
|
|
})
|
|
c := testCache(t)
|
|
|
|
// Seed the cache with an object stored an hour ago.
|
|
if err := c.Store.Put(t.Context(), "k", strings.NewReader("v0"),
|
|
storage.ObjectMeta{StoredAt: time.Now().Add(-time.Hour)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, body := get(t, c, upstream, "k", time.Minute)
|
|
if body != "v1" {
|
|
t.Errorf("expired entry should refetch, body = %q", body)
|
|
}
|
|
// The refetch refreshed the entry: next request is served from cache.
|
|
_, body = get(t, c, upstream, "k", time.Minute)
|
|
if body != "v1" || hits.Load() != 1 {
|
|
t.Errorf("refreshed entry should hit cache, body = %q hits = %d", body, hits.Load())
|
|
}
|
|
}
|
|
|
|
// TestNoStoreServesWithoutCaching covers upstream cache: false — the body
|
|
// reaches the client on every request but never lands in storage.
|
|
func TestNoStoreServesWithoutCaching(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
fmt.Fprint(w, "payload")
|
|
})
|
|
c := testCache(t)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
code, body := getPolicy(t, c, upstream, "k", 0, true)
|
|
if code != http.StatusOK || body != "payload" {
|
|
t.Fatalf("request %d: code = %d body = %q, want 200 %q", i, code, body, "payload")
|
|
}
|
|
}
|
|
if got := hits.Load(); got != 2 {
|
|
t.Errorf("upstream hits = %d, want 2 (nothing may be cached)", got)
|
|
}
|
|
if _, err := c.Store.Stat(t.Context(), "k"); err != storage.ErrNotFound {
|
|
t.Error("no-store response must not be written to storage")
|
|
}
|
|
}
|
|
|
|
// TestNoStoreStillServesCachedCopy: the flag suppresses writes, not reads, so
|
|
// an object cached earlier (e.g. by another upstream of the same vrepo) is
|
|
// still served without touching the upstream.
|
|
func TestNoStoreStillServesCachedCopy(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
fmt.Fprint(w, "payload")
|
|
})
|
|
c := testCache(t)
|
|
|
|
get(t, c, upstream, "k", 0) // cached by a normal (cacheable) upstream
|
|
if _, body := getPolicy(t, c, upstream, "k", 0, true); body != "payload" {
|
|
t.Errorf("body = %q, want %q", body, "payload")
|
|
}
|
|
if got := hits.Load(); got != 1 {
|
|
t.Errorf("upstream hits = %d, want 1 (second request served from cache)", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchNoStore(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
fmt.Fprint(w, "value")
|
|
})
|
|
c := testCache(t)
|
|
|
|
for i := 0; i < 2; i++ {
|
|
body, err := fetchBodyPolicy(c, "k", time.Hour, upstream, true)
|
|
if err != nil || string(body) != "value" {
|
|
t.Fatalf("request %d: body = %q err = %v", i, body, err)
|
|
}
|
|
}
|
|
if got := hits.Load(); got != 2 {
|
|
t.Errorf("upstream hits = %d, want 2", got)
|
|
}
|
|
if _, err := c.Store.Stat(context.Background(), "k"); err != storage.ErrNotFound {
|
|
t.Error("no-store fetch must not be written to storage")
|
|
}
|
|
}
|
|
|
|
func TestUpstreamErrorNotCached(t *testing.T) {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "gone", http.StatusNotFound)
|
|
})
|
|
c := testCache(t)
|
|
code, _ := get(t, c, upstream, "k", 0)
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("code = %d, want 404 passthrough", code)
|
|
}
|
|
if _, err := c.Store.Stat(t.Context(), "k"); err != storage.ErrNotFound {
|
|
t.Error("404 response should not be cached")
|
|
}
|
|
}
|
|
|
|
func fetchBody(c *Cache, key string, ttl time.Duration, upstream http.Handler, stats ...*metrics.VRepoStats) ([]byte, error) {
|
|
return fetchBodyPolicy(c, key, ttl, upstream, false, stats...)
|
|
}
|
|
|
|
func fetchBodyPolicy(c *Cache, key string, ttl time.Duration, upstream http.Handler, noStore bool, stats ...*metrics.VRepoStats) ([]byte, error) {
|
|
ctx := context.Background()
|
|
if len(stats) > 0 {
|
|
ctx = metrics.WithVRepo(ctx, stats[0])
|
|
}
|
|
return c.Fetch(ctx, key, ttl, func(ctx context.Context) (Result, error) {
|
|
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "", nil)
|
|
rec := httptest.NewRecorder()
|
|
upstream.ServeHTTP(rec, req)
|
|
return Result{Resp: rec.Result(), NoStore: noStore}, nil
|
|
})
|
|
}
|
|
|
|
func TestFetchMissThenHit(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
fmt.Fprint(w, "value")
|
|
})
|
|
c := testCache(t)
|
|
|
|
body, err := fetchBody(c, "k", 0, upstream)
|
|
if err != nil || string(body) != "value" {
|
|
t.Fatalf("miss: body=%q err=%v", body, err)
|
|
}
|
|
body, err = fetchBody(c, "k", 0, upstream)
|
|
if err != nil || string(body) != "value" {
|
|
t.Fatalf("hit: body=%q err=%v", body, err)
|
|
}
|
|
if hits.Load() != 1 {
|
|
t.Errorf("upstream hits = %d, want 1", hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestFetchTTLRefetch(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprintf(w, "v%d", hits.Add(1))
|
|
})
|
|
c := testCache(t)
|
|
if err := c.Store.Put(t.Context(), "k", strings.NewReader("v0"),
|
|
storage.ObjectMeta{StoredAt: time.Now().Add(-time.Hour)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := fetchBody(c, "k", time.Minute, upstream)
|
|
if err != nil || string(body) != "v1" {
|
|
t.Errorf("expired entry should refetch, body=%q err=%v", body, err)
|
|
}
|
|
body, _ = fetchBody(c, "k", time.Minute, upstream)
|
|
if string(body) != "v1" || hits.Load() != 1 {
|
|
t.Errorf("refreshed entry should hit cache, body=%q hits=%d", body, hits.Load())
|
|
}
|
|
}
|
|
|
|
func TestFetchNon200NotCached(t *testing.T) {
|
|
var hits atomic.Int32
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits.Add(1)
|
|
http.Error(w, "not here", http.StatusNotFound)
|
|
})
|
|
c := testCache(t)
|
|
_, err := fetchBody(c, "k", 0, upstream)
|
|
var ue *UpstreamError
|
|
if !errors.As(err, &ue) || ue.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("err = %v, want *UpstreamError with 404", err)
|
|
}
|
|
if _, serr := c.Store.Stat(t.Context(), "k"); serr != storage.ErrNotFound {
|
|
t.Error("non-200 response should not be cached")
|
|
}
|
|
// Next Fetch retries upstream.
|
|
if _, err := fetchBody(c, "k", 0, upstream); err == nil || hits.Load() != 2 {
|
|
t.Errorf("hits = %d, want 2 (no caching of failures)", hits.Load())
|
|
}
|
|
}
|
|
|
|
// failingStore fails every Put immediately, without reading the body.
|
|
type failingStore struct{ storage.Store }
|
|
|
|
func (failingStore) Put(context.Context, string, io.Reader, storage.ObjectMeta) error {
|
|
return errors.New("storage is read-only")
|
|
}
|
|
|
|
func TestMetricsMissThenHit(t *testing.T) {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "payload")
|
|
})
|
|
c := testCache(t)
|
|
stats := testVRepo()
|
|
|
|
get(t, c, upstream, "k", 0, stats)
|
|
get(t, c, upstream, "k", 0, stats)
|
|
hits, misses, errors := stats.Snapshot()
|
|
if hits != 1 || misses != 1 || errors != 0 {
|
|
t.Errorf("snapshot = hits:%d misses:%d errors:%d, want 1/1/0", hits, misses, errors)
|
|
}
|
|
}
|
|
|
|
func TestMetricsFetchError(t *testing.T) {
|
|
c := testCache(t)
|
|
stats := testVRepo()
|
|
rec := httptest.NewRecorder()
|
|
req := markVRepo(httptest.NewRequest(http.MethodGet, "/", nil), []*metrics.VRepoStats{stats})
|
|
c.Serve(rec, req, "k", 0, func(ctx context.Context) (Result, error) {
|
|
return Result{}, errors.New("upstream down")
|
|
})
|
|
_, misses, errors := stats.Snapshot()
|
|
if misses != 1 || errors != 1 {
|
|
t.Errorf("snapshot misses:%d errors:%d, want 1/1", misses, errors)
|
|
}
|
|
}
|
|
|
|
func TestMetricsStoreError(t *testing.T) {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "payload")
|
|
})
|
|
c := New(failingStore{testCache(t).Store})
|
|
stats := testVRepo()
|
|
get(t, c, upstream, "k", 0, stats)
|
|
_, misses, errors := stats.Snapshot()
|
|
if misses != 1 || errors != 1 {
|
|
t.Errorf("snapshot misses:%d errors:%d, want 1/1", misses, errors)
|
|
}
|
|
}
|
|
|
|
func TestMetricsFetchPath(t *testing.T) {
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, "value")
|
|
})
|
|
c := testCache(t)
|
|
stats := testVRepo()
|
|
fetchBody(c, "k", 0, upstream, stats)
|
|
fetchBody(c, "k", 0, upstream, stats)
|
|
hits, misses, errors := stats.Snapshot()
|
|
if hits != 1 || misses != 1 || errors != 0 {
|
|
t.Errorf("snapshot = hits:%d misses:%d errors:%d, want 1/1/0", hits, misses, errors)
|
|
}
|
|
}
|
|
|
|
func TestPutFailureDoesNotHang(t *testing.T) {
|
|
payload := strings.Repeat("x", 1<<20) // larger than any pipe buffer
|
|
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, payload)
|
|
})
|
|
c := New(failingStore{testCache(t).Store})
|
|
|
|
done := make(chan string, 1)
|
|
go func() {
|
|
_, body := get(t, c, upstream, "k", 0)
|
|
done <- body
|
|
}()
|
|
select {
|
|
case body := <-done:
|
|
if body != payload {
|
|
t.Errorf("body length = %d, want %d (client should still get the full body)", len(body), len(payload))
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("request hung: Put failing before reading the pipe must not deadlock the client")
|
|
}
|
|
}
|
|
|
|
// serveResult serves key with method, answering misses with a fixed result
|
|
// built by res, and counts how often the fetch ran.
|
|
func serveResult(c *Cache, method, key string, calls *atomic.Int32, res func() Result) *httptest.ResponseRecorder {
|
|
rec := httptest.NewRecorder()
|
|
c.Serve(rec, httptest.NewRequest(method, "/", nil), key, 0, func(ctx context.Context) (Result, error) {
|
|
calls.Add(1)
|
|
return res(), nil
|
|
})
|
|
return rec
|
|
}
|
|
|
|
func notFoundResult(ttl time.Duration) func() Result {
|
|
return func() Result {
|
|
rec := httptest.NewRecorder()
|
|
http.NotFound(rec, nil)
|
|
return Result{Resp: rec.Result(), NotFoundTTL: ttl}
|
|
}
|
|
}
|
|
|
|
func TestNotFoundRemembered(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
for i := 0; i < 3; i++ {
|
|
if rec := serveResult(c, http.MethodGet, "k", &calls, notFoundResult(time.Minute)); rec.Code != http.StatusNotFound {
|
|
t.Fatalf("request %d: got %d, want 404", i, rec.Code)
|
|
}
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("fetch calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestNotFoundWithoutTTLNotRemembered(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
serveResult(c, http.MethodGet, "k", &calls, notFoundResult(0))
|
|
serveResult(c, http.MethodGet, "k", &calls, notFoundResult(0))
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("fetch calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
func TestNotFoundExpires(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
serveResult(c, http.MethodGet, "k", &calls, notFoundResult(20*time.Millisecond))
|
|
time.Sleep(40 * time.Millisecond)
|
|
serveResult(c, http.MethodGet, "k", &calls, notFoundResult(20*time.Millisecond))
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("fetch calls = %d, want 2 once the miss expired", got)
|
|
}
|
|
}
|
|
|
|
func TestFetchNotFoundRemembered(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
for i := 0; i < 2; i++ {
|
|
_, err := c.Fetch(t.Context(), "k", 0, func(ctx context.Context) (Result, error) {
|
|
calls.Add(1)
|
|
return notFoundResult(time.Minute)(), nil
|
|
})
|
|
var ue *UpstreamError
|
|
if !errors.As(err, &ue) || ue.StatusCode != http.StatusNotFound {
|
|
t.Fatalf("call %d: err = %v, want 404 UpstreamError", i, err)
|
|
}
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("fetch calls = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestBodyForHeadStores(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
ok := func() Result {
|
|
rec := httptest.NewRecorder()
|
|
rec.Header().Set("Content-Length", "4")
|
|
rec.WriteString("data")
|
|
return Result{Resp: rec.Result(), BodyForHead: true}
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
storable := false
|
|
c.ServeStoringHead(rec, httptest.NewRequest(http.MethodHead, "/", nil), "k", 0, func(ctx context.Context) (Result, error) {
|
|
calls.Add(1)
|
|
storable = HeadStorable(ctx)
|
|
return ok(), nil
|
|
})
|
|
if !storable {
|
|
t.Fatal("HeadStorable = false for a HEAD under ServeStoringHead")
|
|
}
|
|
if rec.Code != http.StatusOK || rec.Body.Len() != 0 {
|
|
t.Fatalf("HEAD: got %d with %d body bytes, want 200 and none", rec.Code, rec.Body.Len())
|
|
}
|
|
rec = serveResult(c, http.MethodGet, "k", &calls, ok)
|
|
if rec.Code != http.StatusOK || rec.Body.String() != "data" {
|
|
t.Fatalf("GET: got %d %q, want 200 data", rec.Code, rec.Body.String())
|
|
}
|
|
if got := calls.Load(); got != 1 {
|
|
t.Errorf("fetch calls = %d, want 1 (HEAD stored the body)", got)
|
|
}
|
|
}
|
|
|
|
// Without BodyForHead a HEAD miss is relayed and not stored, as before.
|
|
func TestHeadWithoutBodyForHeadNotStored(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
ok := func() Result {
|
|
rec := httptest.NewRecorder()
|
|
rec.WriteString("data")
|
|
return Result{Resp: rec.Result()}
|
|
}
|
|
serveResult(c, http.MethodHead, "k", &calls, ok)
|
|
serveResult(c, http.MethodHead, "k", &calls, ok)
|
|
if got := calls.Load(); got != 2 {
|
|
t.Errorf("fetch calls = %d, want 2", got)
|
|
}
|
|
}
|
|
|
|
// With every background slot taken, a HEAD is not offered the GET-and-store
|
|
// path: its fetch is told to send the HEAD as is.
|
|
func TestHeadStoreSlotsBounded(t *testing.T) {
|
|
c := testCache(t)
|
|
for i := 0; i < maxHeadStores; i++ {
|
|
c.headSlots <- struct{}{}
|
|
}
|
|
storable := true
|
|
c.ServeStoringHead(httptest.NewRecorder(), httptest.NewRequest(http.MethodHead, "/", nil), "k", 0, func(ctx context.Context) (Result, error) {
|
|
storable = HeadStorable(ctx)
|
|
return notFoundResult(0)(), nil
|
|
})
|
|
if storable {
|
|
t.Error("HeadStorable = true with no slot free")
|
|
}
|
|
}
|
|
|
|
// Plain Serve never detaches a HEAD or offers it the store path.
|
|
func TestServeHeadNotStorable(t *testing.T) {
|
|
c := testCache(t)
|
|
storable := true
|
|
c.Serve(httptest.NewRecorder(), httptest.NewRequest(http.MethodHead, "/", nil), "k", 0, func(ctx context.Context) (Result, error) {
|
|
storable = HeadStorable(ctx)
|
|
return notFoundResult(0)(), nil
|
|
})
|
|
if storable {
|
|
t.Error("HeadStorable = true under plain Serve")
|
|
}
|
|
}
|
|
|
|
// A remembered miss tells whoever chains in front how long it has left.
|
|
func TestRememberedMissCarriesTTL(t *testing.T) {
|
|
c := testCache(t)
|
|
var calls atomic.Int32
|
|
serveResult(c, http.MethodGet, "k", &calls, notFoundResult(90*time.Second))
|
|
rec := serveResult(c, http.MethodGet, "k", &calls, notFoundResult(90*time.Second))
|
|
if got := rec.Header().Get(MissTTLHeader); got != "90" {
|
|
t.Errorf("%s = %q, want 90", MissTTLHeader, got)
|
|
}
|
|
}
|