Skip to content

Commit 199ade6

Browse files
authored
fetch: honor HTTP_PROXY and tighten failure modes (#27)
- Set Transport.Proxy to http.ProxyFromEnvironment so HTTP_PROXY, HTTPS_PROXY and NO_PROXY env vars are honored on artifact downloads. Without this, the custom http.Transport silently ignored proxy env vars while the metadata client (using http.DefaultTransport) honored them, causing artifact downloads to hang behind corporate proxies. - Wrap the last underlying dial error instead of returning an opaque "failed to dial any resolved IP" message. - Add a 60s ResponseHeaderTimeout so hung upstreams fail fast rather than waiting the full 5 minute client timeout. - Make the DNS refresh goroutine stoppable via a new Fetcher.Close method to avoid leaking a goroutine per Fetcher.
1 parent 4a7382d commit 199ade6

2 files changed

Lines changed: 81 additions & 20 deletions

File tree

fetch/fetcher.go

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,21 @@ import (
1818
)
1919

2020
const (
21-
dnsRefreshInterval = 5 * time.Minute
22-
dialTimeout = 30 * time.Second
23-
dialKeepAlive = 30 * time.Second
24-
httpClientTimeout = 5 * time.Minute
25-
maxIdleConns = 100
26-
maxIdleConnsPerHost = 10
27-
idleConnTimeout = 90 * time.Second
28-
tlsHandshakeTimeout = 10 * time.Second
29-
defaultMaxRetries = 3
30-
defaultBaseDelay = 500 * time.Millisecond
31-
backoffBase = 2
32-
jitterFactor = 0.1
33-
serverErrThreshold = 500
34-
maxErrBodySize = 1024
21+
dnsRefreshInterval = 5 * time.Minute
22+
dialTimeout = 30 * time.Second
23+
dialKeepAlive = 30 * time.Second
24+
httpClientTimeout = 5 * time.Minute
25+
responseHeaderTimeout = 60 * time.Second
26+
maxIdleConns = 100
27+
maxIdleConnsPerHost = 10
28+
idleConnTimeout = 90 * time.Second
29+
tlsHandshakeTimeout = 10 * time.Second
30+
defaultMaxRetries = 3
31+
defaultBaseDelay = 500 * time.Millisecond
32+
backoffBase = 2
33+
jitterFactor = 0.1
34+
serverErrThreshold = 500
35+
maxErrBodySize = 1024
3536
)
3637

3738
var (
@@ -43,7 +44,7 @@ var (
4344
// Artifact contains the response from fetching an upstream artifact.
4445
type Artifact struct {
4546
Body io.ReadCloser
46-
Size int64 // -1 if unknown
47+
Size int64 // -1 if unknown
4748
ContentType string
4849
ETag string
4950
}
@@ -62,6 +63,7 @@ type Fetcher struct {
6263
maxRetries int
6364
baseDelay time.Duration
6465
authFn func(url string) (headerName, headerValue string)
66+
stop chan struct{}
6567
}
6668

6769
// Option configures a Fetcher.
@@ -105,18 +107,23 @@ func WithAuthFunc(fn func(url string) (headerName, headerValue string)) Option {
105107
}
106108

107109
// NewFetcher creates a new Fetcher with the given options.
110+
// Callers should invoke Close when done to release the DNS refresh goroutine.
108111
func NewFetcher(opts ...Option) *Fetcher {
109-
// Create DNS cache with 5 minute refresh interval
110112
resolver := &dnscache.Resolver{}
113+
stop := make(chan struct{})
111114
go func() {
112115
ticker := time.NewTicker(dnsRefreshInterval)
113116
defer ticker.Stop()
114-
for range ticker.C {
115-
resolver.Refresh(true)
117+
for {
118+
select {
119+
case <-ticker.C:
120+
resolver.Refresh(true)
121+
case <-stop:
122+
return
123+
}
116124
}
117125
}()
118126

119-
// Create custom dialer with DNS caching
120127
dialer := &net.Dialer{
121128
Timeout: dialTimeout,
122129
KeepAlive: dialKeepAlive,
@@ -126,6 +133,7 @@ func NewFetcher(opts ...Option) *Fetcher {
126133
client: &http.Client{
127134
Timeout: httpClientTimeout,
128135
Transport: &http.Transport{
136+
Proxy: http.ProxyFromEnvironment,
129137
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
130138
host, port, err := net.SplitHostPort(addr)
131139
if err != nil {
@@ -135,31 +143,52 @@ func NewFetcher(opts ...Option) *Fetcher {
135143
if err != nil {
136144
return nil, err
137145
}
146+
var lastErr error
138147
for _, ip := range ips {
139148
conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip, port))
140149
if err == nil {
141150
return conn, nil
142151
}
152+
lastErr = err
143153
}
144-
return nil, fmt.Errorf("failed to dial any resolved IP")
154+
if lastErr == nil {
155+
return nil, fmt.Errorf("no IPs resolved for %s", host)
156+
}
157+
return nil, fmt.Errorf("dialing %s: %w", host, lastErr)
145158
},
146159
MaxIdleConns: maxIdleConns,
147160
MaxIdleConnsPerHost: maxIdleConnsPerHost,
148161
IdleConnTimeout: idleConnTimeout,
149162
TLSHandshakeTimeout: tlsHandshakeTimeout,
163+
ResponseHeaderTimeout: responseHeaderTimeout,
150164
ExpectContinueTimeout: 1 * time.Second,
151165
},
152166
},
153167
userAgent: "git-pkgs-proxy/1.0",
154168
maxRetries: defaultMaxRetries,
155169
baseDelay: defaultBaseDelay,
170+
stop: stop,
156171
}
157172
for _, opt := range opts {
158173
opt(f)
159174
}
160175
return f
161176
}
162177

178+
// Close stops the Fetcher's background DNS refresh goroutine.
179+
// It is safe to call Close more than once.
180+
func (f *Fetcher) Close() error {
181+
if f.stop == nil {
182+
return nil
183+
}
184+
select {
185+
case <-f.stop:
186+
default:
187+
close(f.stop)
188+
}
189+
return nil
190+
}
191+
163192
// Fetch downloads an artifact from the given URL.
164193
// The caller must close the returned Artifact.Body when done.
165194
func (f *Fetcher) Fetch(ctx context.Context, url string) (*Artifact, error) {

fetch/fetcher_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"net/http"
88
"net/http/httptest"
9+
"reflect"
910
"strings"
1011
"testing"
1112
"time"
@@ -377,3 +378,34 @@ func TestFetchDNSCaching(t *testing.T) {
377378
t.Errorf("requestCount = %d, want 3", requestCount)
378379
}
379380
}
381+
382+
func TestFetcherHonorsProxyEnvironment(t *testing.T) {
383+
f := NewFetcher()
384+
defer func() { _ = f.Close() }()
385+
386+
transport, ok := f.client.Transport.(*http.Transport)
387+
if !ok {
388+
t.Fatalf("Transport = %T, want *http.Transport", f.client.Transport)
389+
}
390+
if transport.Proxy == nil {
391+
t.Fatal("Transport.Proxy is nil; HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars would be ignored")
392+
}
393+
want := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
394+
got := reflect.ValueOf(transport.Proxy).Pointer()
395+
if got != want {
396+
t.Errorf("Transport.Proxy is not http.ProxyFromEnvironment")
397+
}
398+
if transport.ResponseHeaderTimeout == 0 {
399+
t.Error("Transport.ResponseHeaderTimeout is 0; hung upstreams will only fail at the overall client timeout")
400+
}
401+
}
402+
403+
func TestFetcherCloseStopsGoroutine(t *testing.T) {
404+
f := NewFetcher()
405+
if err := f.Close(); err != nil {
406+
t.Fatalf("Close: %v", err)
407+
}
408+
if err := f.Close(); err != nil {
409+
t.Errorf("second Close returned error: %v", err)
410+
}
411+
}

0 commit comments

Comments
 (0)