From 39d178f62938a5dba2bf4b5a05c9de14d7bf60e1 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Fri, 30 Oct 2026 09:33:56 +0100 Subject: [PATCH] Expire cooldown rewrites when a withheld version becomes available A cached rewrite made with cooldown on stays right until a version it held back leaves cooldown, so it now expires then instead of after a fixed time. Handlers ask cooldown through Proxy.cooldown, which reports each withheld version to the rewrites running at that moment. A rewrite that held nothing back is kept for the shortest cooldown, at most a day. --- docs/configuration.md | 4 +- internal/handler/cargo.go | 2 +- internal/handler/composer.go | 4 +- internal/handler/conda.go | 4 +- internal/handler/cooldown_expiry.go | 164 +++++++++++++++++ internal/handler/cooldown_expiry_test.go | 178 +++++++++++++++++++ internal/handler/cooldown_times.go | 2 +- internal/handler/go_cooldown.go | 2 +- internal/handler/handler.go | 4 + internal/handler/helm.go | 2 +- internal/handler/maven_cooldown.go | 6 +- internal/handler/metadata_access_test.go | 2 +- internal/handler/metadata_gzip_test.go | 2 +- internal/handler/metadata_validators_test.go | 2 +- internal/handler/npm.go | 6 +- internal/handler/npm_rewrite_test.go | 4 +- internal/handler/nuget_cooldown.go | 2 +- internal/handler/pub.go | 2 +- internal/handler/pypi.go | 8 +- internal/handler/rewrite_cache.go | 62 ++----- internal/handler/rewrite_cache_test.go | 43 +++-- 21 files changed, 413 insertions(+), 92 deletions(-) create mode 100644 internal/handler/cooldown_expiry.go create mode 100644 internal/handler/cooldown_expiry_test.go diff --git a/docs/configuration.md b/docs/configuration.md index 941bc0f0..7f622071 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1141,7 +1141,7 @@ Maven caveats: RubyGems, Hex and PyPI keep their publish times outside the metadata clients read: the RubyGems versions API, the Hex HTTP API (`upstream.hex_api`) and the PyPI JSON API. The proxy fetches them alongside each compact index, package or simple page it filters, and for PyPI downloads. Cooldown fails closed: when it applies to a package and those times can't be fetched or parsed, or the metadata can't be rewritten, the request fails with 502, or with 503 and `Retry-After` while the upstream is rate limiting the proxy, instead of serving the metadata unfiltered. The same goes for npm, Composer and pub.dev metadata that can't be rewritten, and for npm tarball downloads whose publish time can't be read. A PyPI file or npm tarball that is already cached is still served when its publish time can't be read; only a download that would have to be fetched fails. A PyPI release's time is the upload time of its newest file. The proxy stores it when a download looks it up, and serves files of that release that are already cached against the stored time without reading the JSON API again. A file that isn't cached yet is always looked up, since it may have been added to the release since, and the newer time is stored. Packages exempted with a duration of `0` are served as before and don't need the times. A PyPI package the JSON API returns 404 for has no times to go by and is served unfiltered. -For RubyGems and Hex, both documents go through the metadata cache. With [`cache_metadata`](#metadata-caching) they are served from it within `metadata_ttl` without asking the upstream, revalidated with their ETags after it, and filtered from the last cached copies, marked stale, while the upstream is down; without it they are still fetched once for concurrent requests. Each document has its own TTL, so the compact index or Hex package can be newer than the cached times and list a version they don't have. The times are then fetched again before filtering, and if that fails, such versions are left out until the times can be read. The filtered index or package carries an ETag of its own, so Bundler and Mix revalidate it with `304 Not Modified`. Publish times are compared with the current time on each request, but the filtered result is kept for up to an hour (see [rewritten metadata cache](#rewritten-metadata-cache)), so a version leaving cooldown can show up that much later; a newly published version shows up within `metadata_ttl`, as with other cached metadata. Upstream errors are answered as for other cached metadata (404, or 502) rather than relayed. +For RubyGems and Hex, both documents go through the metadata cache. With [`cache_metadata`](#metadata-caching) they are served from it within `metadata_ttl` without asking the upstream, revalidated with their ETags after it, and filtered from the last cached copies, marked stale, while the upstream is down; without it they are still fetched once for concurrent requests. Each document has its own TTL, so the compact index or Hex package can be newer than the cached times and list a version they don't have. The times are then fetched again before filtering, and if that fails, such versions are left out until the times can be read. The filtered index or package carries an ETag of its own, so Bundler and Mix revalidate it with `304 Not Modified`. Publish times are compared with the current time on each request, and the filtered result is kept until the first version it held back leaves cooldown (see [rewritten metadata cache](#rewritten-metadata-cache)); a newly published version shows up within `metadata_ttl`, as with other cached metadata. Upstream errors are answered as for other cached metadata (404, or 502) rather than relayed. With cooldown on, the proxy asks npm for full packuments, since the abbreviated ones it uses otherwise carry no publish times. Abbreviated packuments cached before cooldown was turned on are fetched again in full rather than served, including as a stale copy when the upstream is down. When `latest` is in cooldown, the packument tags the newest version out of cooldown instead, and `npm dist-tag ls` shows the same tags. @@ -1420,7 +1420,7 @@ Or via environment variable: `PROXY_ARTIFACT_MAX_SIZE=2GB`. ### Rewritten metadata cache -The npm, Composer, PyPI and Helm handlers rewrite every metadata document they serve, so that download URLs point at the proxy. That means decoding the whole document and encoding it again, and Composer's minified documents are also expanded, which together cost milliseconds per typical package and far more for very large ones; a large Helm `index.yaml` takes over a hundred milliseconds. The proxy keeps rewritten documents in memory, so each distinct upstream document is rewritten once and requests arriving while it is being rewritten wait for that rewrite. New bytes from upstream are rewritten again. A PyPI simple page is cached per representation (HTML or JSON) and per set of versions filtered out of it. With version cooldown enabled, a cached rewrite expires, because cooldown filtering depends on the current time: it is kept for a 24th of the shortest cooldown configured, at least a minute and at most an hour, so a version shows up at most that long after its cooldown ends. With a cooldown of a day or more that is an hour; with `"2h"` somewhere, five minutes. Cached documents carry an `ETag` of their own, and are dated like any other, so clients that revalidate with `If-None-Match` or `If-Modified-Since`, such as Composer, get `304 Not Modified`. The RubyGems compact index and Hex packages filtered by cooldown are kept the same way; they are never served gzip-compressed. +The npm, Composer, PyPI and Helm handlers rewrite every metadata document they serve, so that download URLs point at the proxy. That means decoding the whole document and encoding it again, and Composer's minified documents are also expanded, which together cost milliseconds per typical package and far more for very large ones; a large Helm `index.yaml` takes over a hundred milliseconds. The proxy keeps rewritten documents in memory, so each distinct upstream document is rewritten once and requests arriving while it is being rewritten wait for that rewrite. New bytes from upstream are rewritten again. A PyPI simple page is cached per representation (HTML or JSON) and per set of versions filtered out of it. With version cooldown enabled, a cached rewrite expires when the first version it held back leaves cooldown, since versions it let through stay allowed and a new upstream version changes the document. A rewrite that held nothing back is kept for the shortest cooldown configured, at least a minute and at most a day. Cached documents carry an `ETag` of their own, and are dated like any other, so clients that revalidate with `If-None-Match` or `If-Modified-Since`, such as Composer, get `304 Not Modified`. The RubyGems compact index and Hex packages filtered by cooldown are kept the same way; they are never served gzip-compressed. Each cached document is kept gzip-compressed, when that makes it smaller, and served with `Content-Encoding: gzip` to clients that accept it, as npm, pnpm, Yarn and Composer do. A large packument is compressed once rather than on every request, by the proxy or by a reverse proxy in front of it. Only the compressed copy is kept, typically an eighth of the document, and it is decompressed for the clients that don't accept gzip. Documents rewritten outside the cache are served uncompressed. diff --git a/internal/handler/cargo.go b/internal/handler/cargo.go index 578c9ecd..0c265519 100644 --- a/internal/handler/cargo.go +++ b/internal/handler/cargo.go @@ -171,7 +171,7 @@ func (h *CargoHandler) applyCooldownFiltering(downstreamResponse http.ResponseWr cratePURL := canonicalPackagePURL("cargo", crate.Name) - if !h.proxy.Cooldown.IsAllowed("cargo", cratePURL, publishedAt) { + if !h.proxy.cooldown().IsAllowed("cargo", cratePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering cargo version", "crate", crate.Name, "version", crate.Version, "published", crate.PublishTime) diff --git a/internal/handler/composer.go b/internal/handler/composer.go index 116d106e..ccb42bbb 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -394,7 +394,7 @@ func (h *ComposerHandler) shouldFilterVersion(packagePURL, packageName, version return false } - if !h.proxy.Cooldown.IsAllowed("composer", packagePURL, publishedAt) { + if !h.proxy.cooldown().IsAllowed("composer", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering composer version", "package", packageName, "version", version) return true @@ -731,7 +731,7 @@ func (h *ComposerHandler) serveCachedRelease(w http.ResponseWriter, r *http.Requ if err != nil || ver == nil || !ver.PublishedAt.Valid { return false } - if !h.proxy.Cooldown.IsAllowed("composer", canonicalPackagePURL("composer", packageName), ver.PublishedAt.Time) { + if !h.proxy.cooldown().IsAllowed("composer", canonicalPackagePURL("composer", packageName), ver.PublishedAt.Time) { h.proxy.Logger.Info("cooldown: withholding composer archive", "package", packageName, "version", version) http.Error(w, "version not found", http.StatusNotFound) diff --git a/internal/handler/conda.go b/internal/handler/conda.go index 76a43f2b..3cbaf937 100644 --- a/internal/handler/conda.go +++ b/internal/handler/conda.go @@ -493,7 +493,7 @@ func (f *repodataFilter) inCooldown(filename string, raw []byte) bool { return false } publishedAt := time.Unix(int64(entry.Timestamp)/condaTimestampDivisor, 0) - decision := f.h.proxy.Cooldown.Evaluate("conda", canonicalPackagePURL("conda", entry.Name), publishedAt, f.now) + decision := f.h.proxy.cooldown().Evaluate("conda", canonicalPackagePURL("conda", entry.Name), publishedAt, f.now) if decision.Allowed { return false } @@ -517,7 +517,7 @@ func (h *CondaHandler) recordInCooldown(filename, name, version string, timestam return false } publishedAt := time.Unix(int64(timestamp)/condaTimestampDivisor, 0) - if h.proxy.Cooldown.IsAllowed("conda", canonicalPackagePURL("conda", name), publishedAt) { + if h.proxy.cooldown().IsAllowed("conda", canonicalPackagePURL("conda", name), publishedAt) { return false } h.proxy.Logger.Info("cooldown: filtering conda package", diff --git a/internal/handler/cooldown_expiry.go b/internal/handler/cooldown_expiry.go new file mode 100644 index 00000000..1163bde9 --- /dev/null +++ b/internal/handler/cooldown_expiry.go @@ -0,0 +1,164 @@ +package handler + +import ( + "sync" + "time" + + "github.com/git-pkgs/cooldown" +) + +// A rewrite made with cooldown on stays right until a version it held back +// leaves cooldown: versions it let through stay allowed as time passes, and +// new versions upstream change the document and so the cache key. So the +// cached rewrite expires when the earliest version it withheld becomes +// available, instead of after a fixed time. +// +// Handlers ask cooldown through Proxy.cooldown, which tells every rewrite +// running at that moment when a withheld version becomes available. A +// rewrite may hear of versions another rewrite withheld at the same time; +// that only makes it expire earlier than it has to, never later. + +// maxCooldownRewriteTTL is the longest a rewrite made with cooldown on is +// served, even when it held nothing back. +const maxCooldownRewriteTTL = 24 * time.Hour + +// minCooldownRewriteTTL is the shortest cap on a rewrite made with cooldown +// on, however short the cooldown. +const minCooldownRewriteTTL = time.Minute + +// cooldownRewriteTTL returns the longest a rewrite made with cooldown on is +// served: the shortest cooldown configured, between a minute and a day. It +// only matters when a rewrite held nothing back, or held back a version +// whose availability is unknown. +func (p *Proxy) cooldownRewriteTTL() time.Duration { + ttl := p.ShortestCooldown + switch { + case ttl <= 0 || ttl > maxCooldownRewriteTTL: + return maxCooldownRewriteTTL + case ttl < minCooldownRewriteTTL: + return minCooldownRewriteTTL + } + return ttl +} + +// cooldownWatch holds the rewrites running with cooldown on. +type cooldownWatch struct { + mu sync.Mutex + active map[*cooldownRecorder]struct{} +} + +// cooldownRecorder collects when the versions withheld during one rewrite +// become available. +type cooldownRecorder struct { + // earliest is when the first withheld version becomes available, or + // zero if none was withheld. + earliest time.Time + // unknown is set when a version was withheld without a time it becomes + // available. + unknown bool +} + +// watchCooldown starts recording the versions cooldown holds back for a +// rewrite about to run. unwatchCooldown ends it. +func (p *Proxy) watchCooldown() *cooldownRecorder { + rec := &cooldownRecorder{} + w := &p.cooldownWatch + w.mu.Lock() + defer w.mu.Unlock() + if w.active == nil { + w.active = make(map[*cooldownRecorder]struct{}) + } + w.active[rec] = struct{}{} + return rec +} + +func (p *Proxy) unwatchCooldown(rec *cooldownRecorder) { + w := &p.cooldownWatch + w.mu.Lock() + defer w.mu.Unlock() + delete(w.active, rec) +} + +// cooldownExpiry returns when a rewrite that started at start and recorded +// into rec should expire: when the first version it held back leaves +// cooldown, and no later than cooldownRewriteTTL after start. +func (p *Proxy) cooldownExpiry(rec *cooldownRecorder, start time.Time) time.Time { + w := &p.cooldownWatch + w.mu.Lock() + earliest, unknown := rec.earliest, rec.unknown + w.mu.Unlock() + + limit := start.Add(p.cooldownRewriteTTL()) + if unknown || earliest.IsZero() || earliest.After(limit) { + return limit + } + return earliest +} + +// withheld tells the running rewrites that a version is held back until +// availableAt, zero when that is unknown. +func (p *Proxy) withheld(availableAt time.Time) { + w := &p.cooldownWatch + w.mu.Lock() + defer w.mu.Unlock() + for rec := range w.active { + switch { + case availableAt.IsZero(): + rec.unknown = true + case rec.earliest.IsZero() || availableAt.Before(rec.earliest): + rec.earliest = availableAt + } + } +} + +// cooldown returns the proxy's cooldown policy, telling the running rewrites +// about each version it holds back. Handlers use it rather than p.Cooldown. +func (p *Proxy) cooldown() CooldownPolicy { //nolint:ireturn // nil when cooldown is not configured, as p.Cooldown is + if p.Cooldown == nil { + return nil + } + return recordingCooldown{p: p} +} + +// recordingCooldown is the proxy's cooldown policy, reporting the versions it +// holds back to the running rewrites. +type recordingCooldown struct { + p *Proxy +} + +func (r recordingCooldown) IsAllowed(ecosystem, packagePURL string, publishedAt time.Time) bool { + if r.p.Cooldown.IsAllowed(ecosystem, packagePURL, publishedAt) { + return true + } + r.p.withheld(r.availableAt(ecosystem, packagePURL, publishedAt)) + return false +} + +func (r recordingCooldown) Evaluate(ecosystem, packagePURL string, publishedAt, evaluatedAt time.Time) cooldown.Decision { + d := r.p.Cooldown.Evaluate(ecosystem, packagePURL, publishedAt, evaluatedAt) + if !d.Allowed { + r.p.withheld(d.AvailableAt) + } + return d +} + +func (r recordingCooldown) For(ecosystem, packagePURL string) time.Duration { + return r.p.Cooldown.For(ecosystem, packagePURL) +} + +func (r recordingCooldown) Enabled() bool { + return r.p.Cooldown.Enabled() +} + +// availableAt is when a version IsAllowed held back leaves cooldown, or zero +// when that is unknown. +func (r recordingCooldown) availableAt(ecosystem, packagePURL string, publishedAt time.Time) time.Time { + if publishedAt.IsZero() { + return time.Time{} + } + d := r.p.Cooldown.For(ecosystem, packagePURL) + if d <= 0 { + return time.Time{} + } + return publishedAt.Add(d) +} diff --git a/internal/handler/cooldown_expiry_test.go b/internal/handler/cooldown_expiry_test.go new file mode 100644 index 00000000..fd558455 --- /dev/null +++ b/internal/handler/cooldown_expiry_test.go @@ -0,0 +1,178 @@ +package handler + +import ( + "context" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "github.com/git-pkgs/cooldown" +) + +// TestCachedRewrite_ExpiresWhenWithheldVersionIsAvailable checks that a +// rewrite which held a version back is cached until that version leaves +// cooldown, and no longer. +func TestCachedRewrite_ExpiresWhenWithheldVersionIsAvailable(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + clock := &testClock{t: time.Now()} + proxy.now = clock.now + proxy.SetMetadataRewriteCacheSize(1 << 20) + proxy.Cooldown = &cooldown.Config{Default: "3d"} + purl := canonicalPackagePURL("npm", "left-pad") + // One version is 3d-5h old: it leaves cooldown in five hours. + published := clock.now().Add(-72*time.Hour + 5*time.Hour) + + var calls int + var rewrite keepingRewrite = func(b []byte) ([]byte, []string, error) { + calls++ + if proxy.cooldown().IsAllowed("npm", purl, published) { + return b, []string{"1.0.0", "1.1.0"}, nil + } + return b, []string{"1.0.0"}, nil + } + get := func() { + if _, err := proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", rewrite); err != nil { + t.Fatal(err) + } + } + + get() + clock.advance(5*time.Hour - time.Minute) + get() + if calls != 1 { + t.Errorf("rewrites before the withheld version is available = %d, want 1", calls) + } + clock.advance(time.Minute) + get() + if calls != 2 { + t.Errorf("rewrites once the withheld version is available = %d, want 2", calls) + } +} + +// TestCachedRewrite_NothingWithheldCachedForCap checks that a rewrite which +// held nothing back is cached for cooldownRewriteTTL. +func TestCachedRewrite_NothingWithheldCachedForCap(t *testing.T) { + proxy, _, _, _ := setupTestProxy(t) + clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)} + proxy.now = clock.now + proxy.SetMetadataRewriteCacheSize(1 << 20) + proxy.Cooldown = &cooldown.Config{Default: "2h"} + proxy.ShortestCooldown = 2 * time.Hour + var r countingRewrite + + get := func() { + _, _ = proxy.cachedRewrite(context.Background(), "npm", "http://proxy", "left-pad", []byte("doc"), "", r.keeping) + } + get() + clock.advance(2*time.Hour - time.Second) + get() + if got := r.calls.Load(); got != 1 { + t.Errorf("rewrites within the cap = %d, want 1", got) + } + clock.advance(time.Second) + get() + if got := r.calls.Load(); got != 2 { + t.Errorf("rewrites after the cap = %d, want 2", got) + } +} + +// TestCooldownExpiry checks how one rewrite's recorded decisions set its +// expiry, including decisions made for other rewrites running at the same +// time, which may only make it expire earlier. +func TestCooldownExpiry(t *testing.T) { + p := &Proxy{Cooldown: &cooldown.Config{Default: "3d"}, ShortestCooldown: 72 * time.Hour} + // IsAllowed compares with the wall clock, so the times are relative to it. + start := time.Now() + limit := start.Add(24 * time.Hour) + purl := canonicalPackagePURL("npm", "left-pad") + + rec := p.watchCooldown() + if got := p.cooldownExpiry(rec, start); !got.Equal(limit) { + t.Errorf("nothing withheld: expiry = %v, want the cap %v", got, limit) + } + + other := p.watchCooldown() + p.cooldown().IsAllowed("npm", purl, start.Add(-60*time.Hour)) // available in 12h + p.unwatchCooldown(other) + p.cooldown().IsAllowed("npm", purl, start.Add(-66*time.Hour)) // available in 6h, after other ended + if got, want := p.cooldownExpiry(rec, start), start.Add(6*time.Hour); !got.Equal(want) { + t.Errorf("rec expiry = %v, want the earliest availability %v", got, want) + } + if got, want := p.cooldownExpiry(other, start), start.Add(12*time.Hour); !got.Equal(want) { + t.Errorf("other expiry = %v, want %v: decisions after it ended don't count", got, want) + } + + p.cooldown().Evaluate("npm", purl, start.Add(-71*time.Hour), start) // available in 1h + if got, want := p.cooldownExpiry(rec, start), start.Add(time.Hour); !got.Equal(want) { + t.Errorf("after Evaluate: expiry = %v, want %v", got, want) + } + p.unwatchCooldown(rec) + if len(p.cooldownWatch.active) != 0 { + t.Errorf("%d recorders left after unwatch", len(p.cooldownWatch.active)) + } +} + +// TestHandlersAskCooldownThroughRecorder checks that handlers make cooldown +// decisions through Proxy.cooldown. A decision made on Proxy.Cooldown +// directly would not reach the rewrites running, so a cached rewrite could +// keep hiding a version past its cooldown. +func TestHandlersAskCooldownThroughRecorder(t *testing.T) { + direct := regexp.MustCompile(`\.Cooldown\.(IsAllowed|Evaluate)\(|Cooldown:\s+\w+(\.\w+)*\.Cooldown\b`) + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, f := range files { + if strings.HasSuffix(f, "_test.go") || f == "cooldown_expiry.go" { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + for i, line := range strings.Split(string(src), "\n") { + if direct.MatchString(line) { + t.Errorf("%s:%d asks Proxy.Cooldown directly; use Proxy.cooldown(): %s", f, i+1, strings.TrimSpace(line)) + } + } + } +} + +// TestHandlerRewriteExpiresWithWithheldVersion checks, through the handlers, +// that a cached rewrite expires when the version cooldown held back from it +// becomes available. +func TestHandlerRewriteExpiresWithWithheldVersion(t *testing.T) { + for _, c := range validatorCases { + t.Run(c.ecosystem, func(t *testing.T) { + u := cooldownUpstream(t) // the newest version was published 2h ago + proxy, _, clock := validatorProxyWithDB(t, u, time.Hour) + // Cooldown compares with the wall clock, so the proxy's clock + // follows it here. + clock.t = time.Now() + proxy.Cooldown = &cooldown.Config{Default: "3h"} + proxy.ShortestCooldown = 3 * time.Hour + handler := c.handler(proxy, u) + + if rec := serve(handler, c.path, nil); rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + proxy.rewrites.mu.Lock() + defer proxy.rewrites.mu.Unlock() + if len(proxy.rewrites.entries) != 1 { + t.Fatalf("%d cached rewrites, want 1", len(proxy.rewrites.entries)) + } + for _, el := range proxy.rewrites.entries { + expires := el.Value.(*rewriteEntry).expires + // Published 2h ago with a 3h cooldown: available in an hour, + // to the second the publish time was given in. + if want := time.Now().Add(time.Hour); expires.Before(want.Add(-2*time.Second)) || expires.After(want.Add(time.Second)) { + t.Errorf("rewrite expires %v, want about %v when the withheld version is available", expires, want) + } + } + }) + } +} diff --git a/internal/handler/cooldown_times.go b/internal/handler/cooldown_times.go index 88532cd1..5d95d16a 100644 --- a/internal/handler/cooldown_times.go +++ b/internal/handler/cooldown_times.go @@ -126,7 +126,7 @@ func (d timedDocuments) withheld(p *Proxy, ecosystem, packagePURL, version strin if published.IsZero() { return false } - return !p.Cooldown.IsAllowed(ecosystem, packagePURL, published) + return !p.cooldown().IsAllowed(ecosystem, packagePURL, published) } // decodedMetadataBody returns res's body without its Content-Encoding. The diff --git a/internal/handler/go_cooldown.go b/internal/handler/go_cooldown.go index 90189d47..892e77b3 100644 --- a/internal/handler/go_cooldown.go +++ b/internal/handler/go_cooldown.go @@ -42,7 +42,7 @@ func (h *GoHandler) allowedAt(module string, published time.Time) bool { if published.IsZero() { return true } - return h.proxy.Cooldown.IsAllowed("golang", canonicalPackagePURL("golang", module), published) + return h.proxy.cooldown().IsAllowed("golang", canonicalPackagePURL("golang", module), published) } // writeGoMetadataError answers a failed upstream metadata fetch. diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 5693421a..1bc5ef7a 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -284,6 +284,10 @@ type Proxy struct { // the process started; see metadataDate. now func() time.Time started time.Time + + // cooldownWatch holds the rewrites recording which versions cooldown + // holds back; see cooldown_expiry.go. + cooldownWatch cooldownWatch } // CooldownPolicy supplies version-age filtering and package-specific durations. diff --git a/internal/handler/helm.go b/internal/handler/helm.go index 07e2f74e..fdc000ed 100644 --- a/internal/handler/helm.go +++ b/internal/handler/helm.go @@ -378,7 +378,7 @@ func (h *HelmHandler) buildChartLookup(upstreamURL string, body []byte) (*helmCh func (h *HelmHandler) chartOnCooldown(chartName string, created time.Time) bool { return !created.IsZero() && h.proxy.Cooldown != nil && h.proxy.Cooldown.Enabled() && - !h.proxy.Cooldown.IsAllowed(helmMetadataEcosystem, canonicalPackagePURL(helmMetadataEcosystem, chartName), created) + !h.proxy.cooldown().IsAllowed(helmMetadataEcosystem, canonicalPackagePURL(helmMetadataEcosystem, chartName), created) } type helmChartDownload struct { diff --git a/internal/handler/maven_cooldown.go b/internal/handler/maven_cooldown.go index 4705f700..4da839ee 100644 --- a/internal/handler/maven_cooldown.go +++ b/internal/handler/maven_cooldown.go @@ -222,7 +222,7 @@ func (h *MavenHandler) checkCooldown(w http.ResponseWriter, r *http.Request, sou http.Error(w, "failed to check cooldown", http.StatusBadGateway) return true } - if !h.proxy.Cooldown.IsAllowed("maven", mavenPackagePURL(group, artifact), published) { + if !h.proxy.cooldown().IsAllowed("maven", mavenPackagePURL(group, artifact), published) { h.proxy.Logger.Info("cooldown: withholding maven version", "group", group, "artifact", artifact, "version", version) http.Error(w, errMavenCooldown+": "+canonicalVersionPURL("maven", group+":"+artifact, version), http.StatusForbidden) @@ -307,7 +307,7 @@ func (h *MavenHandler) filterMetadata(ctx context.Context, source mavenSource, a packagePURL := mavenPackagePURL(group, artifact) // No version can be newer than the metadata listing it. if lastUpdated, err := time.Parse(mavenLastUpdatedLayout, doc.lastUpdated); err == nil && - h.proxy.Cooldown.IsAllowed("maven", packagePURL, lastUpdated) { + h.proxy.cooldown().IsAllowed("maven", packagePURL, lastUpdated) { return body, nil } @@ -324,7 +324,7 @@ func (h *MavenHandler) filterMetadata(ctx context.Context, source mavenSource, a if published.IsZero() { continue } - if h.proxy.Cooldown.IsAllowed("maven", packagePURL, published) { + if h.proxy.cooldown().IsAllowed("maven", packagePURL, published) { break } h.proxy.Logger.Info("cooldown: filtering maven version", "group", group, "artifact", artifact, "version", v) diff --git a/internal/handler/metadata_access_test.go b/internal/handler/metadata_access_test.go index 0a64b804..7b5e1cf4 100644 --- a/internal/handler/metadata_access_test.go +++ b/internal/handler/metadata_access_test.go @@ -143,7 +143,7 @@ func TestStoredRewrite_MarksAccess(t *testing.T) { body := []byte(`{}`) proxy.cacheMetadataBlob(t.Context(), "npm", "demo", metadataStoragePath("npm", "demo"), &upstreamMetadata{body: body}) key := rewriteCacheKey("npm", "http://proxy", "demo", body) - if _, err := proxy.rewrites.rewrite(t.Context(), key, body, 0, func(b []byte) ([]byte, error) { return b, nil }); err != nil { + if _, err := proxy.rewrites.rewrite(t.Context(), key, body, nil, func(b []byte) ([]byte, error) { return b, nil }); err != nil { t.Fatal(err) } old := ageMetadataAccess(t, db, "npm", "demo") diff --git a/internal/handler/metadata_gzip_test.go b/internal/handler/metadata_gzip_test.go index b5939c72..57d20cc1 100644 --- a/internal/handler/metadata_gzip_test.go +++ b/internal/handler/metadata_gzip_test.go @@ -184,7 +184,7 @@ func TestRewriteCache_KeepsGzipCopyOnlyWhenSmaller(t *testing.T) { for _, doc := range [][]byte{large, []byte("abc")} { key := rewriteCacheKey("npm", "http://proxy", string(doc[:3]), doc) - if _, err := c.rewrite(t.Context(), key, doc, 0, identity); err != nil { + if _, err := c.rewrite(t.Context(), key, doc, nil, identity); err != nil { t.Fatal(err) } } diff --git a/internal/handler/metadata_validators_test.go b/internal/handler/metadata_validators_test.go index f7c14753..340e3db6 100644 --- a/internal/handler/metadata_validators_test.go +++ b/internal/handler/metadata_validators_test.go @@ -500,7 +500,7 @@ func TestRewriteCacheETag(t *testing.T) { exclaim := func(b []byte) ([]byte, error) { return []byte(string(b) + "!"), nil } key := rewriteCacheKey("npm", "http://proxy", "demo", []byte("doc")) - m, err := c.rewrite(t.Context(), key, []byte("doc"), 0, exclaim) + m, err := c.rewrite(t.Context(), key, []byte("doc"), nil, exclaim) if err != nil || string(m.content()) != "doc!" { t.Fatalf("rewrite = %q, %v", m.content(), err) } diff --git a/internal/handler/npm.go b/internal/handler/npm.go index 537c4b30..7263b3bb 100644 --- a/internal/handler/npm.go +++ b/internal/handler/npm.go @@ -467,7 +467,7 @@ func (h *NPMHandler) rewriteMetadataWithLocal(packageName string, body []byte, l func (h *NPMHandler) npmRewriteOptions(packageName string, local []npmLocalEntry) npm.Options { return npm.Options{ ProxyURL: h.proxyURL, - Cooldown: h.proxy.Cooldown, + Cooldown: h.proxy.cooldown(), DeniedVersions: h.proxy.Denylist.Versions(canonicalPackagePURL("npm", packageName)), EvaluatedAt: time.Now(), LocalVersions: local, @@ -685,7 +685,7 @@ func (h *NPMHandler) versionInCooldown(packageName, version string, metadata fun versionPURL := canonicalVersionPURL("npm", packageName, version) if !routed { if ver, err := h.proxy.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid { - return !h.proxy.Cooldown.IsAllowed("npm", canonicalPackagePURL("npm", packageName), ver.PublishedAt.Time), nil + return !h.proxy.cooldown().IsAllowed("npm", canonicalPackagePURL("npm", packageName), ver.PublishedAt.Time), nil } } @@ -717,7 +717,7 @@ func (h *NPMHandler) versionInCooldown(packageName, version string, metadata fun } } - return !h.proxy.Cooldown.IsAllowed("npm", canonicalPackagePURL("npm", packageName), publishedAt), nil + return !h.proxy.cooldown().IsAllowed("npm", canonicalPackagePURL("npm", packageName), publishedAt), nil } // npmMetadataPath returns the package name and the encoded registry path. diff --git a/internal/handler/npm_rewrite_test.go b/internal/handler/npm_rewrite_test.go index b36b229c..9b8e35be 100644 --- a/internal/handler/npm_rewrite_test.go +++ b/internal/handler/npm_rewrite_test.go @@ -94,7 +94,7 @@ func TestNPMStoredRewriteSkippedWhenStale(t *testing.T) { body := []byte(`{}`) proxy.cacheMetadataBlob(t.Context(), "npm", "demo", metadataStoragePath("npm", "demo"), &upstreamMetadata{body: body}) key := rewriteCacheKey("npm", "http://proxy", "demo", body) - if _, err := proxy.rewrites.rewrite(t.Context(), key, body, 0, func(b []byte) ([]byte, error) { return b, nil }); err != nil { + if _, err := proxy.rewrites.rewrite(t.Context(), key, body, nil, func(b []byte) ([]byte, error) { return b, nil }); err != nil { t.Fatal(err) } if _, ok := proxy.storedRewrite("npm", "demo", "http://proxy", "demo"); !ok { @@ -129,7 +129,7 @@ func TestNPMStoredRewriteRecordsMetadataHit(t *testing.T) { body := []byte(`{"name":"demo"}`) proxy.cacheMetadataBlob(t.Context(), "npm", "hit-demo", metadataStoragePath("npm", "hit-demo"), &upstreamMetadata{body: body}) key := rewriteCacheKey("npm", "http://proxy", "hit-demo", body) - if _, err := proxy.rewrites.rewrite(t.Context(), key, body, 0, func(b []byte) ([]byte, error) { return b, nil }); err != nil { + if _, err := proxy.rewrites.rewrite(t.Context(), key, body, nil, func(b []byte) ([]byte, error) { return b, nil }); err != nil { t.Fatal(err) } diff --git a/internal/handler/nuget_cooldown.go b/internal/handler/nuget_cooldown.go index afb147d5..b9345c5f 100644 --- a/internal/handler/nuget_cooldown.go +++ b/internal/handler/nuget_cooldown.go @@ -204,7 +204,7 @@ func (h *NuGetHandler) nugetLeafAllowed(leaf map[string]any, id string) bool { if err != nil { return true } - return h.proxy.Cooldown.IsAllowed("nuget", canonicalPackagePURL("nuget", strings.ToLower(id)), when) + return h.proxy.cooldown().IsAllowed("nuget", canonicalPackagePURL("nuget", strings.ToLower(id)), when) } func nugetCatalogEntry(leaf map[string]any) map[string]any { diff --git a/internal/handler/pub.go b/internal/handler/pub.go index 3c812983..24fdd02c 100644 --- a/internal/handler/pub.go +++ b/internal/handler/pub.go @@ -247,7 +247,7 @@ func (h *PubHandler) shouldFilterVersion(packagePURL, name, version string, vmap return false } - if !h.proxy.Cooldown.IsAllowed("pub", packagePURL, publishedAt) { + if !h.proxy.cooldown().IsAllowed("pub", packagePURL, publishedAt) { h.proxy.Logger.Info("cooldown: filtering pub version", "package", name, "version", version) return true diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go index fe018d8c..ebb30828 100644 --- a/internal/handler/pypi.go +++ b/internal/handler/pypi.go @@ -318,7 +318,7 @@ func (h *PyPIHandler) fetchFilteredVersions(r *http.Request, name string) (map[s filtered := make(map[string]bool) for version, publishedAt := range times { - if !publishedAt.IsZero() && !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, publishedAt) { + if !publishedAt.IsZero() && !h.proxy.cooldown().IsAllowed("pypi", packagePURL, publishedAt) { filtered[version] = true } } @@ -611,7 +611,7 @@ func (h *PyPIHandler) shouldFilterRelease(packagePURL string, files any) bool { } publishedAt := h.newestUploadTime(filesArr) - return !publishedAt.IsZero() && !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, publishedAt) + return !publishedAt.IsZero() && !h.proxy.cooldown().IsAllowed("pypi", packagePURL, publishedAt) } // versionInCooldown reports whether a version is still inside the cooldown @@ -636,7 +636,7 @@ func (h *PyPIHandler) versionInCooldown(r *http.Request, name, version string) ( return false, nil } h.storeUploadTime(name, version, publishedAt) - return !h.proxy.Cooldown.IsAllowed("pypi", canonicalPackagePURL("pypi", name), publishedAt), nil + return !h.proxy.cooldown().IsAllowed("pypi", canonicalPackagePURL("pypi", name), publishedAt), nil } // storeUploadTime records a release's newest upload time under the version @@ -675,7 +675,7 @@ func (h *PyPIHandler) storedCooldown(r *http.Request, name, version, filename st if err != nil || ver == nil || !ver.PublishedAt.Valid { return false, false } - if !h.proxy.Cooldown.IsAllowed("pypi", packagePURL, ver.PublishedAt.Time) { + if !h.proxy.cooldown().IsAllowed("pypi", packagePURL, ver.PublishedAt.Time) { return true, true } cached, err := h.proxy.HasCachedArtifact(r.Context(), "pypi", name, version, filename) diff --git a/internal/handler/rewrite_cache.go b/internal/handler/rewrite_cache.go index b2dc9921..fd455a78 100644 --- a/internal/handler/rewrite_cache.go +++ b/internal/handler/rewrite_cache.go @@ -35,8 +35,8 @@ import ( // depends on is fixed for the life of the process: the proxy URL is part of // the key, and the denylist is loaded at startup. The exception is cooldown, // which depends on the time of the request: with cooldown on, entries expire -// after Proxy.cooldownRewriteTTL, so a version is held back at most that long -// past its cooldown. +// when the first version they held back leaves cooldown (see +// cooldown_expiry.go). // // Each entry keeps the ETag of its output, so clients can revalidate their // copy without the output being hashed again on every request. Dates are not @@ -65,36 +65,6 @@ type rewriteEntry struct { expires time.Time } -// maxCooldownRewriteTTL is the longest a rewrite made with cooldown on is -// served. Versions leave cooldown as time passes, so a cached rewrite would -// otherwise keep hiding them. Cooldowns run to days, and a package manager -// that checks every lockfile entry on each install, as pnpm 12 does, would -// otherwise have the whole lockfile rewritten again on every install more -// than a minute after the last. -const maxCooldownRewriteTTL = time.Hour - -// minCooldownRewriteTTL is the shortest a rewrite made with cooldown on is -// served, however short the cooldown. -const minCooldownRewriteTTL = time.Minute - -// cooldownRewriteShare is the share of the shortest cooldown a rewrite made -// with cooldown on is served for, as a divisor. -const cooldownRewriteShare = 24 - -// cooldownRewriteTTL returns how long a rewrite made with cooldown on is -// served: a 24th of the shortest cooldown configured, so a version is held -// back at most about 4% past its cooldown, kept between a minute and an hour. -func (p *Proxy) cooldownRewriteTTL() time.Duration { - ttl := p.ShortestCooldown / cooldownRewriteShare - switch { - case ttl <= 0 || ttl > maxCooldownRewriteTTL: - return maxCooldownRewriteTTL - case ttl < minCooldownRewriteTTL: - return minCooldownRewriteTTL - } - return ttl -} - // size is how much of the cache's room the entry takes. func (e *rewriteEntry) size() int64 { return int64(len(e.plain) + len(e.gzipped)) @@ -279,7 +249,7 @@ func (c *rewriteCache) remove(el *list.Element) { // shared. A caller waiting on another's rewrite leaves when ctx ends; the // rewrite itself always runs to completion, so the result is cached for the // next request. -func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, ttl time.Duration, rewrite func([]byte) ([]byte, error)) (rewrittenMetadata, error) { +func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, expiry func(start time.Time) time.Time, rewrite func([]byte) ([]byte, error)) (rewrittenMetadata, error) { if c == nil { return rewriteWithETag(in, rewrite) } @@ -302,16 +272,17 @@ func (c *rewriteCache) rewrite(ctx context.Context, key string, in []byte, ttl t c.inFlight[key] = f c.mu.Unlock() - // The entry expires counting from before the rewrite, so it never - // outlives the time its cooldown decisions were made at by more than ttl. - var expires time.Time - if ttl > 0 { - expires = c.clock().Add(ttl) - } + // expiry is given the time from before the rewrite, so an entry never + // outlives the time its cooldown decisions were made at. + start := c.clock() defer func() { c.mu.Lock() delete(c.inFlight, key) if f.err == nil { + var expires time.Time + if expiry != nil { + expires = expiry(start) + } c.add(&rewriteEntry{key: key, rewrittenMetadata: f.m.compact(), expires: expires}) } c.mu.Unlock() @@ -370,7 +341,8 @@ func (c *rewriteCache) add(e *rewriteEntry) { // metadata cache stored it, and is empty otherwise; when known it keys the // cache, so the document isn't hashed again. // -// With cooldown on, the cached rewrite expires after p.cooldownRewriteTTL. Its +// With cooldown on, the cached rewrite expires when the first version it +// held back leaves cooldown, and after p.cooldownRewriteTTL at the latest. Its // ETag is the hash of its output, and it is dated like any other, so clients // revalidate with either validator and get the gzip copy. // @@ -390,11 +362,15 @@ func (p *Proxy) cachedRewrite(ctx context.Context, ecosystem, proxyURL, name str if digest == "" { key = rewriteCacheKey(ecosystem, proxyURL, name, in) } - var ttl time.Duration + var expiry func(time.Time) time.Time if cooldown { - ttl = p.cooldownRewriteTTL() + // Recording runs from before the rewrite until after it, so it + // hears of every version the rewrite holds back. + rec := p.watchCooldown() + defer p.unwatchCooldown(rec) + expiry = func(start time.Time) time.Time { return p.cooldownExpiry(rec, start) } } - m, err := p.rewrites.rewrite(ctx, key, in, ttl, rewriteOnly) + m, err := p.rewrites.rewrite(ctx, key, in, expiry, rewriteOnly) if err == nil { m.lastModified = p.metadataDate(ecosystem, proxyURL, name, m.etag) } diff --git a/internal/handler/rewrite_cache_test.go b/internal/handler/rewrite_cache_test.go index 7188634a..3f4c062d 100644 --- a/internal/handler/rewrite_cache_test.go +++ b/internal/handler/rewrite_cache_test.go @@ -44,7 +44,7 @@ func TestRewriteCache_RepeatServesCachedRewrite(t *testing.T) { key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte("doc")) for range 3 { - m, err := c.rewrite(context.Background(), key, []byte("doc"), 0, r.fn) + m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn) if err != nil || string(m.content()) != "DOC" { t.Fatalf("rewrite = %q, %v", m.content(), err) } @@ -60,7 +60,7 @@ func TestRewriteCache_NewUpstreamBytesRewriteAgain(t *testing.T) { for _, doc := range []string{"v1", "v2"} { key := rewriteCacheKey("npm", "http://proxy", "left-pad", []byte(doc)) - m, err := c.rewrite(context.Background(), key, []byte(doc), 0, r.fn) + m, err := c.rewrite(context.Background(), key, []byte(doc), nil, r.fn) if err != nil || string(m.content()) != strings.ToUpper(doc) { t.Fatalf("rewrite(%s) = %q, %v", doc, m.content(), err) } @@ -82,7 +82,7 @@ func TestRewriteCache_ConcurrentRequestsShareRewrite(t *testing.T) { outs := make(chan string, n) run := func() { - m, err := c.rewrite(context.Background(), key, []byte("doc"), 0, r.fn) + m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn) if err != nil { t.Error(err) } @@ -115,20 +115,20 @@ func TestRewriteCache_WaiterLeavingKeepsRewrite(t *testing.T) { first := make(chan error, 1) go func() { - _, err := c.rewrite(context.Background(), key, []byte("doc"), 0, r.fn) + _, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn) first <- err }() <-r.entered ctx, cancel := context.WithCancel(context.Background()) cancel() - if _, err := c.rewrite(ctx, key, []byte("doc"), 0, r.fn); !errors.Is(err, context.Canceled) { + if _, err := c.rewrite(ctx, key, []byte("doc"), nil, r.fn); !errors.Is(err, context.Canceled) { t.Errorf("waiter err = %v, want context.Canceled", err) } close(r.release) if err := <-first; err != nil { t.Fatal(err) } - if m, err := c.rewrite(context.Background(), key, []byte("doc"), 0, r.fn); err != nil || string(m.content()) != "DOC" { + if m, err := c.rewrite(context.Background(), key, []byte("doc"), nil, r.fn); err != nil || string(m.content()) != "DOC" { t.Errorf("cached rewrite = %q, %v", m.content(), err) } if got := r.calls.Load(); got != 1 { @@ -142,18 +142,18 @@ func TestRewriteCache_EvictsLeastRecentlyUsed(t *testing.T) { keyFor := func(doc string) string { return rewriteCacheKey("npm", "http://proxy", doc, []byte(doc)) } for _, doc := range []string{"aaa", "bbb"} { - _, _ = c.rewrite(context.Background(), keyFor(doc), []byte(doc), 0, r.fn) + _, _ = c.rewrite(context.Background(), keyFor(doc), []byte(doc), nil, r.fn) } - _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), 0, r.fn) // aaa is now the most recent - _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), 0, r.fn) // evicts bbb + _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), nil, r.fn) // aaa is now the most recent + _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), nil, r.fn) // evicts bbb before := r.calls.Load() - _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), 0, r.fn) - _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), 0, r.fn) + _, _ = c.rewrite(context.Background(), keyFor("aaa"), []byte("aaa"), nil, r.fn) + _, _ = c.rewrite(context.Background(), keyFor("ccc"), []byte("ccc"), nil, r.fn) if got := r.calls.Load() - before; got != 0 { t.Errorf("aaa and ccc rewritten %d times, want both still cached", got) } - _, _ = c.rewrite(context.Background(), keyFor("bbb"), []byte("bbb"), 0, r.fn) + _, _ = c.rewrite(context.Background(), keyFor("bbb"), []byte("bbb"), nil, r.fn) if got := r.calls.Load() - before; got != 1 { t.Errorf("bbb should have been evicted and rewritten") } @@ -164,7 +164,7 @@ func TestRewriteCache_OutputLargerThanCacheIsNotStored(t *testing.T) { var r countingRewrite key := rewriteCacheKey("npm", "http://proxy", "big", []byte("big")) - _, _ = c.rewrite(context.Background(), key, []byte("big"), 0, r.fn) + _, _ = c.rewrite(context.Background(), key, []byte("big"), nil, r.fn) if c.len() != 0 { t.Errorf("cached %d entries, want 0", c.len()) } @@ -177,7 +177,7 @@ func TestRewriteCache_ErrorsAreNotCached(t *testing.T) { key := rewriteCacheKey("npm", "http://proxy", "broken", []byte("doc")) for range 2 { - if _, err := c.rewrite(context.Background(), key, []byte("doc"), 0, failing); err == nil { + if _, err := c.rewrite(context.Background(), key, []byte("doc"), nil, failing); err == nil { t.Fatal("expected the rewrite error") } } @@ -232,7 +232,7 @@ func TestRewriteCacheExpiredEntryFreesRoom(t *testing.T) { clock := &testClock{t: time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)} c.now = clock.now upper := func(b []byte) ([]byte, error) { return bytes.ToUpper(b), nil } - if _, err := c.rewrite(context.Background(), "k", []byte("doc"), time.Minute, upper); err != nil { + if _, err := c.rewrite(context.Background(), "k", []byte("doc"), func(start time.Time) time.Time { return start.Add(time.Minute) }, upper); err != nil { t.Fatal(err) } clock.advance(time.Minute) @@ -302,17 +302,16 @@ func (c *rewriteCache) len() int { return len(c.entries) } -// TestCooldownRewriteTTL checks that a rewrite filtered by cooldown is cached -// for a 24th of the shortest cooldown, between a minute and an hour. +// TestCooldownRewriteTTL checks that the cap on a rewrite filtered by +// cooldown is the shortest cooldown, between a minute and a day. func TestCooldownRewriteTTL(t *testing.T) { for _, tc := range []struct { shortest, want time.Duration }{ - {0, time.Hour}, - {72 * time.Hour, time.Hour}, - {24 * time.Hour, time.Hour}, - {2 * time.Hour, 5 * time.Minute}, - {10 * time.Minute, time.Minute}, + {0, 24 * time.Hour}, + {72 * time.Hour, 24 * time.Hour}, + {2 * time.Hour, 2 * time.Hour}, + {10 * time.Second, time.Minute}, } { p := &Proxy{ShortestCooldown: tc.shortest} if got := p.cooldownRewriteTTL(); got != tc.want {