Skip to content

Commit bc8dc45

Browse files
committed
retry version check on transient proxy failures
1 parent dd5ab5f commit bc8dc45

2 files changed

Lines changed: 135 additions & 13 deletions

File tree

version/version.go

Lines changed: 54 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os/exec"
99
"slices"
1010
"strings"
11+
"time"
1112

1213
"github.com/goccy/go-json"
1314
"golang.org/x/mod/semver"
@@ -81,6 +82,9 @@ func getLatestVersion() (string, error) {
8182
proxies = append(proxies, goproxyDefault)
8283
}
8384

85+
client := &http.Client{Timeout: 10 * time.Second}
86+
87+
var lastErr error
8488
for _, proxy := range proxies {
8589
proxy = strings.TrimSpace(proxy)
8690
proxy = strings.TrimRight(proxy, "/")
@@ -89,24 +93,61 @@ func getLatestVersion() (string, error) {
8993
}
9094

9195
url := fmt.Sprintf("%s/github.com/%s/%s/@latest", proxy, repoOwner, repoName)
92-
resp, err := http.Get(url)
93-
if err != nil {
94-
continue
96+
version, err := fetchLatestWithRetry(client, url)
97+
if err == nil {
98+
return version, nil
9599
}
96-
defer resp.Body.Close()
100+
lastErr = err
101+
}
97102

98-
body, err := io.ReadAll(resp.Body)
99-
if err != nil {
100-
continue
101-
}
103+
if lastErr != nil {
104+
return "", fmt.Errorf("failed to fetch latest version: %w", lastErr)
105+
}
106+
return "", fmt.Errorf("failed to fetch latest version")
107+
}
102108

103-
var version struct{ Version string }
104-
if err = json.Unmarshal(body, &version); err != nil {
105-
continue
109+
// fetchLatestWithRetry queries a single proxy, retrying a few times because
110+
// proxy.golang.org occasionally resets the connection or returns a transient
111+
// 5xx. A single drop should not fail the whole version check.
112+
func fetchLatestWithRetry(client *http.Client, url string) (string, error) {
113+
const maxAttempts = 3
114+
var lastErr error
115+
for attempt := 1; attempt <= maxAttempts; attempt++ {
116+
version, err := fetchLatestFromProxy(client, url)
117+
if err == nil {
118+
return version, nil
106119
}
120+
lastErr = err
121+
if attempt < maxAttempts {
122+
time.Sleep(time.Duration(attempt) * 200 * time.Millisecond)
123+
}
124+
}
125+
return "", lastErr
126+
}
107127

108-
return version.Version, nil
128+
func fetchLatestFromProxy(client *http.Client, url string) (string, error) {
129+
resp, err := client.Get(url)
130+
if err != nil {
131+
return "", err
109132
}
133+
defer resp.Body.Close()
110134

111-
return "", fmt.Errorf("failed to fetch latest version")
135+
body, err := io.ReadAll(resp.Body)
136+
if err != nil {
137+
return "", err
138+
}
139+
140+
if resp.StatusCode != http.StatusOK {
141+
return "", fmt.Errorf("unexpected status %s from %s", resp.Status, url)
142+
}
143+
144+
var version struct{ Version string }
145+
if err := json.Unmarshal(body, &version); err != nil {
146+
return "", fmt.Errorf("invalid response from %s: %w", url, err)
147+
}
148+
if version.Version == "" {
149+
return "", fmt.Errorf("empty version in response from %s", url)
150+
}
151+
152+
return version.Version, nil
112153
}

version/version_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package version
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
)
8+
9+
func TestFetchLatestFromProxy(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
status int
13+
body string
14+
want string
15+
wantErr bool
16+
}{
17+
{name: "ok", status: http.StatusOK, body: `{"Version":"v1.2.3"}`, want: "v1.2.3"},
18+
{name: "non-200", status: http.StatusInternalServerError, body: "boom", wantErr: true},
19+
{name: "invalid json", status: http.StatusOK, body: "not json", wantErr: true},
20+
{name: "empty version", status: http.StatusOK, body: `{"Version":""}`, wantErr: true},
21+
}
22+
23+
for _, tt := range tests {
24+
t.Run(tt.name, func(t *testing.T) {
25+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
26+
w.WriteHeader(tt.status)
27+
_, _ = w.Write([]byte(tt.body))
28+
}))
29+
defer server.Close()
30+
31+
got, err := fetchLatestFromProxy(server.Client(), server.URL)
32+
if tt.wantErr {
33+
if err == nil {
34+
t.Fatalf("fetchLatestFromProxy() error = nil, want error")
35+
}
36+
return
37+
}
38+
if err != nil {
39+
t.Fatalf("fetchLatestFromProxy() unexpected error: %v", err)
40+
}
41+
if got != tt.want {
42+
t.Fatalf("fetchLatestFromProxy() = %q, want %q", got, tt.want)
43+
}
44+
})
45+
}
46+
}
47+
48+
// A single transient failure should not fail the version check; the retry
49+
// must recover once the proxy responds successfully.
50+
func TestFetchLatestWithRetryRecoversFromTransientFailure(t *testing.T) {
51+
var calls int
52+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+
calls++
54+
if calls < 2 {
55+
// Simulate a dropped/erroring proxy response.
56+
hj, ok := w.(http.Hijacker)
57+
if ok {
58+
conn, _, err := hj.Hijack()
59+
if err == nil {
60+
conn.Close()
61+
return
62+
}
63+
}
64+
w.WriteHeader(http.StatusInternalServerError)
65+
return
66+
}
67+
_, _ = w.Write([]byte(`{"Version":"v1.2.3"}`))
68+
}))
69+
defer server.Close()
70+
71+
got, err := fetchLatestWithRetry(server.Client(), server.URL)
72+
if err != nil {
73+
t.Fatalf("fetchLatestWithRetry() unexpected error: %v", err)
74+
}
75+
if got != "v1.2.3" {
76+
t.Fatalf("fetchLatestWithRetry() = %q, want %q", got, "v1.2.3")
77+
}
78+
if calls < 2 {
79+
t.Fatalf("expected a retry, got %d call(s)", calls)
80+
}
81+
}

0 commit comments

Comments
 (0)