Skip to content

Commit 9ac8a99

Browse files
authored
fix(experiments): do not cache a failed npm lookup (#1787)
## Bug `Guesser.checkNPMPackageWithClient` (`internal/experiments/guesser.go`) writes its result to the cache manager on every exit path, including the failure paths: request errors, timeouts, non-200/404 statuses (5xx, 429), and an unparseable body. Those entries use the default 2h TTL, and the cache is checked before the network on the next call. A failed lookup tells us nothing about whether the package exists, but it gets cached as `exists: false`. For two hours, every registry search that involves that repository loses its npm `install_cmd` / `repository_info`. ## Trigger The runtime's registry search (`Runtime.SearchRegistryServers`) builds a guesser with the shared cache manager. Batch lookups use a 3s client timeout. One slow npm response, a brief network drop or an npm 5xx during a search caches the failure. Refreshing the registry (`RefreshRegistryCache`) does not help, because it only drops the `registry-servers:` keys, while the guess entries live under `npm:<package>`. ## Fix Cache only the definitive answers: 200 (package found) and 404 (package does not exist). Failed lookups are returned without being cached, so the next search tries again. ## Test `guesser_transient_cache_test.go` drives `checkNPMPackageWithClient` with a scripted `http.RoundTripper`, so it makes no real network calls: - `TestCheckNPMPackage_TransientFailureNotCached`: the first response is a network error, a 503 or a 429, and the second is a 200. The second call must reach the transport and report the package as found. - `TestCheckNPMPackage_DefinitiveAnswersCached`: a 200 and a 404 are each still served from the cache on the second call. ## Commands run - `CGO_ENABLED=0 go test -count=1 -run 'TestCheckNPMPackage_(TransientFailureNotCached|DefinitiveAnswersCached)|TestGuesser_CacheEntriesAreStampedInternal|TestCaching|TestNilCacheManager' ./internal/experiments/`: ok - `CGO_ENABLED=0 go vet ./internal/experiments/`: ok - On current `main` (and again with `guesser.go` reverted), all three `TransientFailureNotCached` subtests fail: `expected: 2, actual: 1` transport calls, because the second call returned the cached failure.
1 parent 4fd87dc commit 9ac8a99

2 files changed

Lines changed: 98 additions & 4 deletions

File tree

‎internal/experiments/guesser.go‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,6 @@ func (g *Guesser) checkNPMPackageWithClient(ctx context.Context, packageName str
278278
req, err := http.NewRequestWithContext(ctx, "GET", npmURL, http.NoBody)
279279
if err != nil {
280280
info.Error = fmt.Sprintf("Failed to create request: %v", err)
281-
g.cacheInfo(cacheKey, info)
282281
return info
283282
}
284283

@@ -288,11 +287,13 @@ func (g *Guesser) checkNPMPackageWithClient(ctx context.Context, packageName str
288287
resp, err := client.Do(req)
289288
if err != nil {
290289
info.Error = fmt.Sprintf("Request failed: %v", err)
291-
g.cacheInfo(cacheKey, info)
292290
return info
293291
}
294292
defer resp.Body.Close()
295293

294+
// Only a 200 or a 404 is cached. A failed lookup (timeout, network error,
295+
// 5xx, 429) says nothing about the package, and caching it would hide the
296+
// npm hint for the whole TTL, past a registry cache refresh.
296297
if resp.StatusCode == 404 {
297298
// Package doesn't exist
298299
g.cacheInfo(cacheKey, info)
@@ -301,14 +302,12 @@ func (g *Guesser) checkNPMPackageWithClient(ctx context.Context, packageName str
301302

302303
if resp.StatusCode != 200 {
303304
info.Error = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, resp.Status)
304-
g.cacheInfo(cacheKey, info)
305305
return info
306306
}
307307

308308
var npmInfo NPMPackageInfo
309309
if err := json.NewDecoder(resp.Body).Decode(&npmInfo); err != nil {
310310
info.Error = fmt.Sprintf("Failed to parse response: %v", err)
311-
g.cacheInfo(cacheKey, info)
312311
return info
313312
}
314313

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package experiments
2+
3+
import (
4+
"context"
5+
"errors"
6+
"io"
7+
"net/http"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
)
14+
15+
// scriptedTransport answers each request with the next scripted response.
16+
type scriptedTransport struct {
17+
responses []func(*http.Request) (*http.Response, error)
18+
calls int
19+
}
20+
21+
func (s *scriptedTransport) RoundTrip(r *http.Request) (*http.Response, error) {
22+
i := s.calls
23+
s.calls++
24+
if i >= len(s.responses) {
25+
return nil, errors.New("unexpected request")
26+
}
27+
return s.responses[i](r)
28+
}
29+
30+
func npmStatus(code int, body string) func(*http.Request) (*http.Response, error) {
31+
return func(r *http.Request) (*http.Response, error) {
32+
return &http.Response{
33+
StatusCode: code,
34+
Status: http.StatusText(code),
35+
Body: io.NopCloser(strings.NewReader(body)),
36+
Header: http.Header{"Content-Type": []string{"application/json"}},
37+
Request: r,
38+
}, nil
39+
}
40+
}
41+
42+
const npmFoundBody = `{"name":"@acme/tool","description":"d","dist-tags":{"latest":"1.0.0"}}`
43+
44+
// A failed lookup (timeout, network error, 5xx) says nothing about whether the
45+
// package exists, so it must not be cached for the 2h TTL: a registry refresh
46+
// does not clear the guess cache, and the npm install hint would stay missing.
47+
func TestCheckNPMPackage_TransientFailureNotCached(t *testing.T) {
48+
cases := map[string]func(*http.Request) (*http.Response, error){
49+
"network error": func(*http.Request) (*http.Response, error) {
50+
return nil, errors.New("dial tcp: i/o timeout")
51+
},
52+
"server error": npmStatus(http.StatusServiceUnavailable, ""),
53+
"rate limited": npmStatus(http.StatusTooManyRequests, ""),
54+
}
55+
for name, failure := range cases {
56+
t.Run(name, func(t *testing.T) {
57+
guesser, db := setupTestGuesser(t)
58+
defer db.Close()
59+
60+
rt := &scriptedTransport{responses: []func(*http.Request) (*http.Response, error){
61+
failure,
62+
npmStatus(http.StatusOK, npmFoundBody),
63+
}}
64+
client := &http.Client{Transport: rt}
65+
66+
first := guesser.checkNPMPackageWithClient(context.Background(), "@acme/tool", client)
67+
require.False(t, first.Exists)
68+
69+
second := guesser.checkNPMPackageWithClient(context.Background(), "@acme/tool", client)
70+
assert.Equal(t, 2, rt.calls, "the retry must reach the registry, not the cached failure")
71+
assert.True(t, second.Exists)
72+
assert.Equal(t, "1.0.0", second.Version)
73+
})
74+
}
75+
}
76+
77+
func TestCheckNPMPackage_DefinitiveAnswersCached(t *testing.T) {
78+
for name, answer := range map[string]func(*http.Request) (*http.Response, error){
79+
"found": npmStatus(http.StatusOK, npmFoundBody),
80+
"not found": npmStatus(http.StatusNotFound, ""),
81+
} {
82+
t.Run(name, func(t *testing.T) {
83+
guesser, db := setupTestGuesser(t)
84+
defer db.Close()
85+
86+
rt := &scriptedTransport{responses: []func(*http.Request) (*http.Response, error){answer}}
87+
client := &http.Client{Transport: rt}
88+
89+
first := guesser.checkNPMPackageWithClient(context.Background(), "@acme/tool", client)
90+
second := guesser.checkNPMPackageWithClient(context.Background(), "@acme/tool", client)
91+
assert.Equal(t, 1, rt.calls)
92+
assert.Equal(t, first.Exists, second.Exists)
93+
})
94+
}
95+
}

0 commit comments

Comments
 (0)