Skip to content

Commit 96a6e5f

Browse files
committed
fix: Address the review on the plugin download retry
Redact the URL that url.Error prints verbatim, so wrapping a transport error no longer re-exposes the signed token. Retry connection refused, unreachable networks and DNS failures, and match the Windows WSA socket messages that the synthetic syscall.E* constants never match. Migrate the getURLLocation probe onto the same classifier with bounded backoff and redacted URLs. Make the retry delays injectable so the tests stop sleeping through the real backoff, and assert the hijack error on the test goroutine.
1 parent cded9c0 commit 96a6e5f

3 files changed

Lines changed: 173 additions & 23 deletions

File tree

managedplugin/download.go

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -71,28 +71,31 @@ func getURLLocation(ctx context.Context, org string, name string, version string
7171
var (
7272
err404 = errors.New("404")
7373
err401 = errors.New("401")
74-
err429 = errors.New("429")
7574
)
7675

7776
options := []retry.Option{
7877
retry.RetryIf(func(err error) bool {
79-
return err == err401 || err == err429
78+
// The classifier treats 401 as permanent; this probe has always
79+
// retried it because the GitHub asset host returns it spuriously.
80+
return errors.Is(err, err401) || isRetryableDownloadError(err)
8081
}),
8182
retry.Context(ctx),
82-
retry.Attempts(RetryAttempts),
83-
retry.Delay(RetryWaitTime),
83+
retry.Attempts(downloadRetryAttempts),
84+
retry.Delay(downloadRetryDelay),
85+
retry.MaxDelay(downloadRetryMaxDelay),
8486
retry.LastErrorOnly(true),
8587
}
8688
retrier := retry.New(options...)
8789
for _, downloadURL := range urls {
90+
urlForLog := redactURLQuery(downloadURL)
8891
err := retrier.Do(func() error {
8992
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
9093
if err != nil {
91-
return fmt.Errorf("failed create request %s: %w", downloadURL, err)
94+
return fmt.Errorf("failed create request %s: %w", urlForLog, redactURLError(err))
9295
}
9396
resp, err := http.DefaultClient.Do(req)
9497
if err != nil {
95-
return fmt.Errorf("failed to get url %s: %w", downloadURL, err)
98+
return fmt.Errorf("failed to get url %s: %w", urlForLog, redactURLError(err))
9699
}
97100
resp.Body.Close()
98101
// Check server response
@@ -102,17 +105,18 @@ func getURLLocation(ctx context.Context, org string, name string, version string
102105
case http.StatusNotFound:
103106
return err404
104107
case http.StatusUnauthorized:
105-
fmt.Printf("Failed downloading %s with status code %d. Retrying\n", downloadURL, resp.StatusCode)
108+
fmt.Printf("Failed downloading %s with status code %d. Retrying\n", urlForLog, resp.StatusCode)
106109
return err401
107-
case http.StatusTooManyRequests:
108-
fmt.Printf("Failed downloading %s with status code %d. Retrying\n", downloadURL, resp.StatusCode)
109-
return err429
110110
default:
111-
fmt.Printf("Failed downloading %s with status code %d\n", downloadURL, resp.StatusCode)
112-
return fmt.Errorf("statusCode %d", resp.StatusCode)
111+
if isRetryableStatusCode(resp.StatusCode) {
112+
fmt.Printf("Failed downloading %s with status code %d. Retrying\n", urlForLog, resp.StatusCode)
113+
} else {
114+
fmt.Printf("Failed downloading %s with status code %d\n", urlForLog, resp.StatusCode)
115+
}
116+
return &httpStatusError{statusCode: resp.StatusCode}
113117
}
114118
})
115-
if err == err404 {
119+
if errors.Is(err, err404) {
116120
continue
117121
}
118122
return downloadURL, err
@@ -346,9 +350,9 @@ func downloadFile(ctx context.Context, localPath string, downloadURL string, dop
346350
options := []retry.Option{
347351
retry.RetryIf(isRetryableDownloadError),
348352
retry.Context(ctx),
349-
retry.Attempts(RetryAttempts),
350-
retry.Delay(RetryWaitTime),
351-
retry.MaxDelay(MaxRetryWaitTime),
353+
retry.Attempts(downloadRetryAttempts),
354+
retry.Delay(downloadRetryDelay),
355+
retry.MaxDelay(downloadRetryMaxDelay),
352356
}
353357
retrier := retry.New(options...)
354358
err = retrier.Do(func() error {
@@ -362,13 +366,13 @@ func downloadFile(ctx context.Context, localPath string, downloadURL string, dop
362366
// Get the data
363367
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
364368
if err != nil {
365-
return fmt.Errorf("failed create request %s: %w", urlForLog, err)
369+
return fmt.Errorf("failed create request %s: %w", urlForLog, redactURLError(err))
366370
}
367371

368372
// Do http request
369373
resp, err := http.DefaultClient.Do(req)
370374
if err != nil {
371-
return fmt.Errorf("failed to get url %s: %w", urlForLog, err)
375+
return fmt.Errorf("failed to get url %s: %w", urlForLog, redactURLError(err))
372376
}
373377
defer resp.Body.Close()
374378
// Check server response

managedplugin/download_retry.go

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ var (
1717
errShortRead = errors.New("truncated response body")
1818
)
1919

20+
// Overridable so tests do not pay the real backoff.
21+
var (
22+
downloadRetryAttempts = uint(RetryAttempts)
23+
downloadRetryDelay = RetryWaitTime
24+
downloadRetryMaxDelay = MaxRetryWaitTime
25+
)
26+
2027
type httpStatusError struct {
2128
statusCode int
2229
}
@@ -34,8 +41,9 @@ func isRetryableStatusCode(statusCode int) bool {
3441
}
3542

3643
// Go does not export the HTTP/2 stream and connection error types used by the
37-
// net/http transport, so the mid-body resets we get from the asset CDN can only
38-
// be matched on their message.
44+
// net/http transport, and on Windows the syscall.E* constants are synthetic
45+
// values that never match a real WSA socket error, so these failures can only be
46+
// matched on their message.
3947
var transientTransportMessages = []string{
4048
"stream error",
4149
"server sent goaway",
@@ -46,6 +54,14 @@ var transientTransportMessages = []string{
4654
"server closed idle connection",
4755
"transport connection broken",
4856
"i/o timeout",
57+
"connection refused",
58+
"no such host",
59+
"network is unreachable",
60+
"no route to host",
61+
// Windows WSAECONNRESET, WSAECONNREFUSED and WSAETIMEDOUT respectively.
62+
"forcibly closed by the remote host",
63+
"actively refused it",
64+
"did not properly respond after a period of time",
4965
}
5066

5167
func isRetryableDownloadError(err error) bool {
@@ -69,8 +85,18 @@ func isRetryableDownloadError(err error) bool {
6985
errors.Is(err, io.ErrUnexpectedEOF),
7086
errors.Is(err, io.EOF),
7187
errors.Is(err, syscall.ECONNRESET),
88+
errors.Is(err, syscall.ECONNREFUSED),
7289
errors.Is(err, syscall.EPIPE),
73-
errors.Is(err, syscall.ETIMEDOUT):
90+
errors.Is(err, syscall.ETIMEDOUT),
91+
errors.Is(err, syscall.EHOSTUNREACH),
92+
errors.Is(err, syscall.ENETUNREACH):
93+
return true
94+
}
95+
96+
// The asset host is fixed, so a resolution failure against it is a resolver
97+
// problem rather than a bad name.
98+
var dnsErr *net.DNSError
99+
if errors.As(err, &dnsErr) {
74100
return true
75101
}
76102

@@ -100,6 +126,17 @@ func redactURLQuery(rawURL string) string {
100126
return parsed.String()
101127
}
102128

129+
// redactURLError rewrites the URL that *url.Error prints verbatim. Wrapping such
130+
// an error re-exposes the signed token that redactURLQuery removed from the
131+
// surrounding message.
132+
func redactURLError(err error) error {
133+
var urlErr *url.Error
134+
if errors.As(err, &urlErr) {
135+
urlErr.URL = redactURLQuery(urlErr.URL)
136+
}
137+
return err
138+
}
139+
103140
// IsTransientDownloadError reports whether err is a transient plugin download
104141
// failure - a network or server-side problem rather than a bad plugin reference.
105142
// Callers use it to keep advice about plugin resolution off errors that have

managedplugin/download_retry_test.go

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
"net/http/httptest"
1212
"os"
1313
"path/filepath"
14+
"strings"
1415
"syscall"
1516
"testing"
17+
"time"
1618

1719
"github.com/stretchr/testify/require"
1820
)
@@ -80,9 +82,14 @@ func TestRedactURLQuery(t *testing.T) {
8082
// attempt writes part of the body and then the connection drops mid-copy. The retry
8183
// must start the file from scratch rather than append to the partial bytes.
8284
func TestDownloadFileRetriesTruncatedBody(t *testing.T) {
85+
fastRetries(t)
86+
8387
body := []byte("cloudquery-plugin-binary-payload")
8488

85-
var attempts int
89+
var (
90+
attempts int
91+
hijackErr error
92+
)
8693
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
8794
attempts++
8895
if attempts == 1 {
@@ -92,7 +99,10 @@ func TestDownloadFileRetriesTruncatedBody(t *testing.T) {
9299
w.(http.Flusher).Flush()
93100
// Close the connection mid-body so the client sees a truncated response.
94101
conn, _, err := w.(http.Hijacker).Hijack()
95-
require.NoError(t, err)
102+
if err != nil {
103+
hijackErr = err
104+
return
105+
}
96106
conn.Close()
97107
return
98108
}
@@ -102,6 +112,7 @@ func TestDownloadFileRetriesTruncatedBody(t *testing.T) {
102112

103113
localPath := filepath.Join(t.TempDir(), "plugin.zip")
104114
checksum, err := downloadFile(context.Background(), localPath, server.URL, DownloaderOptions{NoProgress: true})
115+
require.NoError(t, hijackErr)
105116
require.NoError(t, err)
106117
require.Equal(t, 2, attempts)
107118

@@ -141,6 +152,8 @@ func TestDownloadFileDoesNotRetryNotFound(t *testing.T) {
141152
}
142153

143154
func TestDownloadFileRetriesServerError(t *testing.T) {
155+
fastRetries(t)
156+
144157
body := []byte("payload")
145158

146159
var attempts int
@@ -162,6 +175,8 @@ func TestDownloadFileRetriesServerError(t *testing.T) {
162175
}
163176

164177
func TestDownloadFileGivesUpAfterRetryAttempts(t *testing.T) {
178+
fastRetries(t)
179+
165180
var attempts int
166181
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
167182
attempts++
@@ -182,3 +197,97 @@ func sha256Hex(b []byte) string {
182197
s.Write(b)
183198
return fmt.Sprintf("%x", s.Sum(nil))
184199
}
200+
201+
// fastRetries removes the real backoff so the retry tests do not sleep through it.
202+
func fastRetries(t *testing.T) {
203+
t.Helper()
204+
205+
delay, maxDelay := downloadRetryDelay, downloadRetryMaxDelay
206+
downloadRetryDelay, downloadRetryMaxDelay = time.Millisecond, time.Millisecond
207+
t.Cleanup(func() {
208+
downloadRetryDelay, downloadRetryMaxDelay = delay, maxDelay
209+
})
210+
}
211+
212+
// TestDownloadFileRedactsSignedTokenFromTransportErrors covers the leak that
213+
// url.Error reopens: it prints its URL verbatim, so wrapping one puts the signed
214+
// token back into the message once per attempt.
215+
func TestDownloadFileRedactsSignedTokenFromTransportErrors(t *testing.T) {
216+
fastRetries(t)
217+
218+
const token = "SUPERSECRETTOKEN"
219+
220+
server := httptest.NewUnstartedServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
221+
server.Config.ConnState = func(c net.Conn, state http.ConnState) {
222+
if state == http.StateActive {
223+
c.Close()
224+
}
225+
}
226+
server.Start()
227+
t.Cleanup(server.Close)
228+
229+
localPath := filepath.Join(t.TempDir(), "plugin.zip")
230+
_, err := downloadFile(context.Background(), localPath, server.URL+"/asset?verify="+token, DownloaderOptions{NoProgress: true})
231+
require.Error(t, err)
232+
require.NotContains(t, err.Error(), token)
233+
require.NotContains(t, err.Error(), "verify=")
234+
}
235+
236+
func TestDownloadFileRetriesConnectionRefused(t *testing.T) {
237+
fastRetries(t)
238+
239+
listener, err := net.Listen("tcp", "127.0.0.1:0")
240+
require.NoError(t, err)
241+
addr := listener.Addr().String()
242+
require.NoError(t, listener.Close())
243+
244+
localPath := filepath.Join(t.TempDir(), "plugin.zip")
245+
_, err = downloadFile(context.Background(), localPath, "http://"+addr+"/asset", DownloaderOptions{NoProgress: true})
246+
require.Error(t, err)
247+
require.Equal(t, int(downloadRetryAttempts), strings.Count(err.Error(), "connection refused"))
248+
}
249+
250+
func TestIsRetryableDownloadErrorWindowsSocketMessages(t *testing.T) {
251+
cases := []struct {
252+
name string
253+
msg string
254+
}{
255+
{name: "WSAECONNRESET", msg: "wsarecv: An existing connection was forcibly closed by the remote host."},
256+
{name: "WSAECONNREFUSED", msg: "connectex: No connection could be made because the target machine actively refused it."},
257+
{name: "WSAETIMEDOUT", msg: "connectex: A connection attempt failed because the connected party did not properly respond after a period of time."},
258+
}
259+
260+
for _, tc := range cases {
261+
t.Run(tc.name, func(t *testing.T) {
262+
require.True(t, isRetryableDownloadError(fmt.Errorf("failed to get url: %w", errors.New(tc.msg))))
263+
})
264+
}
265+
}
266+
267+
func TestIsRetryableDownloadErrorDNS(t *testing.T) {
268+
require.True(t, isRetryableDownloadError(fmt.Errorf("dial: %w", &net.DNSError{Err: "no such host", Name: "assets.cloudquery.io", IsNotFound: true})))
269+
}
270+
271+
// TestTransportErrorFromRealRequestIsRetryable pins the gap that left
272+
// getURLLocation aborting on the first attempt: a transport failure surfaces as a
273+
// wrapped *url.Error, which its old identity comparison could never match.
274+
func TestTransportErrorFromRealRequestIsRetryable(t *testing.T) {
275+
server := httptest.NewUnstartedServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {}))
276+
server.Config.ConnState = func(c net.Conn, state http.ConnState) {
277+
if state == http.StateActive {
278+
c.Close()
279+
}
280+
}
281+
server.Start()
282+
t.Cleanup(server.Close)
283+
284+
resp, err := http.Get(server.URL + "/asset?verify=SUPERSECRETTOKEN")
285+
if resp != nil {
286+
resp.Body.Close()
287+
}
288+
require.Error(t, err)
289+
290+
wrapped := fmt.Errorf("failed to get url %s: %w", redactURLQuery(server.URL), redactURLError(err))
291+
require.True(t, isRetryableDownloadError(wrapped))
292+
require.NotContains(t, wrapped.Error(), "SUPERSECRETTOKEN")
293+
}

0 commit comments

Comments
 (0)