From d0a5e0a9d209ce26606c0d7f8c743d17f404794f Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Mon, 20 Apr 2026 09:56:24 -0600 Subject: [PATCH] fix: split connection and download timeouts in httpcache ConnTimeout (default 5s) caps TCP connect + TLS handshake via net.Dialer and Transport.TLSHandshakeTimeout. Timeout (default 5m) caps the overall request including body read. Previously a single 30s timeout covered both, which was too short for large downloads and too long for connection failures. --- net/httpcache/httpcache.go | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/net/httpcache/httpcache.go b/net/httpcache/httpcache.go index c3bafa2..4af559b 100644 --- a/net/httpcache/httpcache.go +++ b/net/httpcache/httpcache.go @@ -3,13 +3,17 @@ package httpcache import ( "fmt" "io" + "net" "net/http" "os" "sync" "time" ) -const defaultTimeout = 30 * time.Second +const ( + defaultConnTimeout = 5 * time.Second // TCP connect + TLS handshake + defaultTimeout = 5 * time.Minute // overall including body read +) // Syncer is implemented by any value that can fetch a remote resource and // report whether it changed. Both *Cacher and *gitshallow.Repo satisfy this. @@ -43,7 +47,8 @@ func (NopSyncer) Fetch() (bool, error) { return false, nil } type Cacher struct { URL string Path string - Timeout time.Duration // 0 uses 30s + ConnTimeout time.Duration // 0 uses 5s; caps TCP connect + TLS handshake + Timeout time.Duration // 0 uses 5m; caps overall request including body read MaxAge time.Duration // 0 disables; skip HTTP if file mtime is within this MinInterval time.Duration // 0 disables; skip HTTP if last Fetch attempt was within this Username string // Basic Auth — not forwarded on redirects @@ -86,6 +91,10 @@ func (c *Cacher) Fetch() (updated bool, err error) { } c.lastChecked = time.Now() + connTimeout := c.ConnTimeout + if connTimeout == 0 { + connTimeout = defaultConnTimeout + } timeout := c.Timeout if timeout == 0 { timeout = defaultTimeout @@ -102,20 +111,26 @@ func (c *Cacher) Fetch() (updated bool, err error) { req.Header.Set("If-Modified-Since", c.lastMod) } + transport := &http.Transport{ + DialContext: (&net.Dialer{Timeout: connTimeout}).DialContext, + TLSHandshakeTimeout: connTimeout, + } + var client *http.Client if c.Username != "" { req.SetBasicAuth(c.Username, c.Password) // Strip auth before following any redirect — presigned URLs (e.g. R2) // must not receive our credentials. client = &http.Client{ - Timeout: timeout, + Timeout: timeout, + Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { req.Header.Del("Authorization") return nil }, } } else { - client = &http.Client{Timeout: timeout} + client = &http.Client{Timeout: timeout, Transport: transport} } resp, err := client.Do(req)