Skip to content

Commit b9dcbd2

Browse files
committed
Handle cooldown lookup failures and reuse download indexes
1 parent 3ab7dcc commit b9dcbd2

13 files changed

Lines changed: 294 additions & 192 deletions

‎docs/configuration.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1201,6 +1201,10 @@ Maven caveats:
12011201

12021202
Helm uses each release's `created` time from the repository's `index.yaml`. Releases in cooldown are left out of the index the proxy serves, and their archives aren't served; a release without `created` is let through. Charts pulled over OCI have no publish time and aren't held back.
12031203

1204+
Cargo, RubyGems, Hex, pub.dev, Conda and Helm check artifact downloads against cooldown, including files cached before the policy changed. When a publish-time lookup fails, an already-cached file is still served; an uncached download fails with 502, or 503 with `Retry-After` when the rate-limit gate holds the request. Conda checks timestamps per file because builds of one version can have different publish times.
1205+
1206+
Conda repodata and Helm indexes are parsed once per cached document for download checks. With `cache_metadata` off, the parsed index is kept for one minute, including the fetch, so an install can reuse it across files. Conda streams repodata when its compressed copy exceeds `metadata_max_size`, under the same decompressed size bound as the metadata filter.
1207+
12041208
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.
12051209

12061210
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.

‎internal/handler/cargo.go‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,17 @@ func (h *CargoHandler) publishTime(r *http.Request, name, version string) (time.
197197
return time.Time{}, false, err
198198
}
199199
for line := range bytes.SplitSeq(body, []byte("\n")) {
200-
if vers, _, _ := lookupJSONString(line, "vers"); vers != version {
200+
if len(bytes.TrimSpace(line)) == 0 {
201201
continue
202202
}
203-
pubtime, _, _ := lookupJSONString(line, "pubtime")
204-
published, err := time.Parse(time.RFC3339, pubtime)
203+
var entry crateIndexEntry
204+
if err := json.Unmarshal(line, &entry); err != nil {
205+
return time.Time{}, false, err
206+
}
207+
if entry.Version != version {
208+
continue
209+
}
210+
published, err := time.Parse(time.RFC3339, entry.PublishTime)
205211
return published, err == nil, nil
206212
}
207213
return time.Time{}, false, nil
@@ -238,8 +244,8 @@ func (h *CargoHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
238244
h.proxy.Logger.Debug("cargo download request",
239245
"crate", name, "version", version, "filename", filename)
240246

241-
if h.proxy.withholdInCooldown(w, downloadVersion{
242-
ecosystem: "cargo", policyName: name, storedName: name, version: version,
247+
if h.proxy.withholdInCooldown(w, r, downloadVersion{
248+
ecosystem: "cargo", policyName: name, storedName: name, version: version, cacheName: name, filename: filename,
243249
lookup: func() (time.Time, bool, error) { return h.publishTime(r, name, version) },
244250
}) {
245251
return

‎internal/handler/conda.go‎

Lines changed: 16 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -141,18 +141,16 @@ func (h *CondaHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
141141

142142
// Builds and subdirs of one version each have their own timestamp, so
143143
// the check is per file and keeps no time in the shared versions row.
144-
if h.proxy.withholdInCooldown(w, downloadVersion{
145-
ecosystem: "conda", policyName: name, version: version,
144+
// Subdirs distinguish cached files with the same filename.
145+
cacheFilename := arch + "/" + filename
146+
if h.proxy.withholdInCooldown(w, r, downloadVersion{
147+
ecosystem: "conda", policyName: name, version: version, cacheName: packageName, filename: cacheFilename,
146148
lookup: func() (time.Time, bool, error) { return h.publishTime(r, channel, arch, filename) },
147149
}) {
148150
return
149151
}
150152

151153
upstreamURL := h.upstreamURL + r.URL.Path
152-
// The same filename can hold different bytes in each subdir, so the
153-
// subdir is part of the cache key.
154-
cacheFilename := arch + "/" + filename
155-
156154
result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "conda", packageName, version, cacheFilename, upstreamURL)
157155
if err != nil {
158156
h.proxy.serveArtifactError(w, err, "failed to fetch package")
@@ -540,26 +538,21 @@ func (h *CondaHandler) metadataCacheKey(urlPath string) string {
540538
return hashedCacheKey(h.upstreamURL, urlPath)
541539
}
542540

543-
// publishTime reads a package file's timestamp from the subdir's
544-
// repodata.json, fetched with the cache key and encoding proxyCached uses.
545-
// The repodata is indexed once per document (see publishIndex), since a
546-
// subdir's index runs to hundreds of megabytes and an install downloads many
547-
// files from it. A compressed copy over metadata_max_size can't be cached,
548-
// so it is streamed from upstream for each check, as handleRepodata does.
541+
// publishTime streams compressed repodata when it exceeds metadata_max_size.
549542
func (h *CondaHandler) publishTime(r *http.Request, channel, arch, filename string) (time.Time, bool, error) {
550543
repodataPath := "/" + channel + "/" + arch + "/repodata.json"
551544
cacheKey := h.metadataCacheKey(repodataPath)
552-
res := h.proxy.lookupMetadata(r.Context(), "conda", cacheKey, h.upstreamURL+repodataPath, "*/*", "gzip", nil)
553-
var index map[string]time.Time
554-
var err error
555-
switch {
556-
case errors.Is(res.err, ErrMetadataTooLarge):
557-
index, err = h.streamRepodataIndex(r, h.upstreamURL+repodataPath)
558-
case res.err != nil:
559-
err = res.err
560-
default:
561-
index, err = publishIndex(h.proxy, "conda\x00"+cacheKey, res, h.indexRepodata)
562-
}
545+
index, err := publishIndex(h.proxy, "conda\x00"+cacheKey, func() metadataResult {
546+
return h.proxy.lookupMetadata(r.Context(), "conda", cacheKey, h.upstreamURL+repodataPath, "*/*", "gzip", nil)
547+
}, func(res metadataResult) (map[string]time.Time, error) {
548+
if errors.Is(res.err, ErrMetadataTooLarge) {
549+
return h.streamRepodataIndex(r, h.upstreamURL+repodataPath)
550+
}
551+
if res.err != nil {
552+
return nil, res.err
553+
}
554+
return h.indexRepodata(res.body, res.contentEncoding)
555+
})
563556
if err != nil {
564557
return time.Time{}, false, err
565558
}

‎internal/handler/cooldown_times.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ import (
1616
// through.
1717
type publishTimes map[string]time.Time
1818

19+
func (p *Proxy) lookupPublishTime(ctx context.Context, t timedMetadata, version string) (time.Time, bool, error) {
20+
var times publishTimes
21+
validate := func(body []byte) error {
22+
var err error
23+
times, err = t.parseTimes(body)
24+
return err
25+
}
26+
res := p.lookupMetadata(ctx, t.ecosystem, t.timesKey, t.timesURL, t.timesAccept, "", validate)
27+
if res.err != nil {
28+
return time.Time{}, false, res.err
29+
}
30+
published, ok := times[version]
31+
return published, ok && !published.IsZero(), nil
32+
}
33+
1934
// timedMetadata describes metadata whose publish times live in a second
2035
// document, as with the RubyGems compact index and the versions API, or a
2136
// Hex package and the Hex HTTP API. Both are read through the metadata cache.

‎internal/handler/download_cooldown.go‎

Lines changed: 56 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,22 @@ type downloadVersion struct {
2424
// keeps no time, and every check asks lookup.
2525
storedName string
2626
version string
27+
cacheName string
28+
filename string
2729
lookup publishTimeLookup
2830
}
2931

3032
// withholdInCooldown reports whether a download is inside the cooldown
31-
// window, and if so answers it with a 404. Filtering metadata is not enough on
32-
// its own: lockfiles record artifact URLs, so clients reach the download path
33-
// without reading the metadata first, and a cached artifact would be served
34-
// even after the policy was raised.
35-
//
36-
// A publish time never changes, so the stored versions row is read first and
37-
// lookup is only asked for a version the proxy has no time for yet; the time
38-
// it finds is stored. A version with no usable publish time is allowed
39-
// through, matching how the metadata filters treat it.
40-
func (p *Proxy) withholdInCooldown(w http.ResponseWriter, d downloadVersion) bool {
41-
if !p.versionInCooldown(d) {
33+
// window, or a lookup failure prevents an uncached download.
34+
func (p *Proxy) withholdInCooldown(w http.ResponseWriter, r *http.Request, d downloadVersion) bool {
35+
withheld, err := p.versionInCooldown(d)
36+
if err != nil {
37+
if !p.serveCachedDespiteCooldownError(w, r, d.ecosystem, d.cacheName, d.version, d.filename, err) {
38+
p.writeCooldownLookupError(w, d.ecosystem, d.policyName, err)
39+
}
40+
return true
41+
}
42+
if !withheld {
4243
return false
4344
}
4445
p.Logger.Info("cooldown: withholding "+d.ecosystem+" download",
@@ -47,31 +48,26 @@ func (p *Proxy) withholdInCooldown(w http.ResponseWriter, d downloadVersion) boo
4748
return true
4849
}
4950

50-
func (p *Proxy) versionInCooldown(d downloadVersion) bool {
51-
if p.Cooldown == nil || !p.Cooldown.Enabled() {
52-
return false
53-
}
51+
func (p *Proxy) versionInCooldown(d downloadVersion) (bool, error) {
5452
policyKey := canonicalPackagePURL(d.ecosystem, d.policyName)
55-
if p.Cooldown.For(d.ecosystem, policyKey) <= 0 {
56-
return false
53+
if !p.cooldownApplies(d.ecosystem, d.policyName) {
54+
return false, nil
5755
}
5856

5957
var versionPURL string
6058
if d.storedName != "" {
6159
versionPURL = canonicalVersionPURL(d.ecosystem, d.storedName, d.version)
6260
if ver, err := p.DB.GetVersionByPURL(versionPURL); err == nil && ver != nil && ver.PublishedAt.Valid {
63-
return !p.Cooldown.IsAllowed(d.ecosystem, policyKey, ver.PublishedAt.Time)
61+
return !p.cooldown().IsAllowed(d.ecosystem, policyKey, ver.PublishedAt.Time), nil
6462
}
6563
}
6664

6765
published, ok, err := d.lookup()
6866
if err != nil {
69-
p.Logger.Warn("cooldown: could not read publish time for download check",
70-
"ecosystem", d.ecosystem, "name", d.policyName, "version", d.version, "error", err)
71-
return false
67+
return false, err
7268
}
7369
if !ok {
74-
return false
70+
return false, nil
7571
}
7672

7773
if versionPURL != "" {
@@ -81,7 +77,7 @@ func (p *Proxy) versionInCooldown(d downloadVersion) bool {
8177
}
8278
}
8379

84-
return !p.Cooldown.IsAllowed(d.ecosystem, policyKey, published)
80+
return !p.cooldown().IsAllowed(d.ecosystem, policyKey, published), nil
8581
}
8682

8783
// maxPublishIndexes bounds how many documents publishIndexCache keeps an
@@ -99,27 +95,40 @@ type publishIndexCache struct {
9995
}
10096

10197
type publishIndexEntry struct {
102-
digest string
103-
index any
98+
digest string
99+
index any
100+
expires time.Time
104101
}
105102

106-
// publishIndex returns build's index of res, reusing the one built for the
107-
// same document under key. res.digest identifies the document; without one,
108-
// as when the metadata cache is off, the index is built for this call alone.
109-
func publishIndex[V any](p *Proxy, key string, res metadataResult, build func([]byte, string) (map[string]V, error)) (map[string]V, error) {
103+
// publishIndex reuses parsed documents by digest, or for a minute without one.
104+
func publishIndex[V any](p *Proxy, key string, load func() metadataResult, build func(metadataResult) (V, error)) (V, error) { //nolint:ireturn // Supports timestamp maps and Helm lookups.
110105
c := &p.publishIndexes
111-
if res.digest == "" {
112-
return build(res.body, res.contentEncoding)
106+
fresh := func() (V, bool) {
107+
c.mu.Lock()
108+
defer c.mu.Unlock()
109+
if e, ok := c.entries[key]; ok && !e.expires.IsZero() && p.clock().Before(e.expires) {
110+
return e.index.(V), true
111+
}
112+
var zero V
113+
return zero, false
113114
}
114-
c.mu.Lock()
115-
if e, ok := c.entries[key]; ok && e.digest == res.digest {
116-
c.mu.Unlock()
117-
return e.index.(map[string]V), nil
115+
if index, ok := fresh(); ok {
116+
return index, nil
118117
}
119-
c.mu.Unlock()
120-
121-
v, err, _ := c.group.Do(key+"\x00"+res.digest, func() (any, error) {
122-
index, err := build(res.body, res.contentEncoding)
118+
v, err, _ := c.group.Do(key, func() (any, error) {
119+
if index, ok := fresh(); ok {
120+
return index, nil
121+
}
122+
res := load()
123+
if res.err == nil && res.digest != "" {
124+
c.mu.Lock()
125+
entry, ok := c.entries[key]
126+
c.mu.Unlock()
127+
if ok && entry.digest == res.digest {
128+
return entry.index, nil
129+
}
130+
}
131+
index, err := build(res)
123132
if err != nil {
124133
return nil, err
125134
}
@@ -134,11 +143,16 @@ func publishIndex[V any](p *Proxy, key string, res metadataResult, build func([]
134143
break
135144
}
136145
}
137-
c.entries[key] = publishIndexEntry{digest: res.digest, index: index}
146+
entry := publishIndexEntry{digest: res.digest, index: index}
147+
if res.digest == "" || !p.CacheMetadata {
148+
entry.expires = p.clock().Add(time.Minute)
149+
}
150+
c.entries[key] = entry
138151
return index, nil
139152
})
140153
if err != nil {
141-
return nil, err
154+
var zero V
155+
return zero, err
142156
}
143-
return v.(map[string]V), nil
157+
return v.(V), nil
144158
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
"testing"
6+
)
7+
8+
func TestGemDownloadSharesPublishTimeMetadata(t *testing.T) {
9+
u, proxy, h := gemCacheSetup(t)
10+
wantStatus(t, serveGet(t, h, gemInfoPath), http.StatusOK, "")
11+
seedPackage(t, proxy.DB, proxy.Storage.(*mockStorage), "gem", "testgem", "1.0.0", "testgem-1.0.0.gem", "gem bytes")
12+
wantStatus(t, serveGet(t, h, "/gems/testgem-1.0.0.gem"), http.StatusOK, "")
13+
if n, _ := u.counts(gemVersionsPath); n != 1 {
14+
t.Fatalf("versions API fetched %d times, want 1", n)
15+
}
16+
}
17+
18+
func TestHexDownloadSharesPublishTimeMetadata(t *testing.T) {
19+
u, proxy, h, _ := hexCacheSetup(t, 0)
20+
wantStatus(t, serveGet(t, h, hexPackagePath), http.StatusOK, "")
21+
seedPackage(t, proxy.DB, proxy.Storage.(*mockStorage), "hex", "testpkg", "1.0.0", "testpkg-1.0.0.tar", "hex bytes")
22+
wantStatus(t, serveGet(t, h, "/tarballs/testpkg-1.0.0.tar"), http.StatusOK, "")
23+
if n, _ := u.counts(hexAPIPath); n != 1 {
24+
t.Fatalf("Hex API fetched %d times, want 1", n)
25+
}
26+
}

0 commit comments

Comments
 (0)