feat: make upstream timeouts configurable per-remote
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

Keep the dial/TLS/response-header timeouts as defaults, but allow each
remote to override them via upstream_dial_timeout / upstream_tls_timeout /
upstream_response_header_timeout (seconds; 0 = default). Clients are cached
by their timeout set so remotes sharing a configuration also share a
connection pool.

Refs #67
This commit is contained in:
2026-07-02 20:16:43 +10:00
parent 1476120c7b
commit 1e879126d7
6 changed files with 124 additions and 30 deletions
+4 -4
View File
@@ -154,7 +154,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
}
}
resp, err := upstreamClient.Do(req)
resp, err := clientForRemote(remote).Do(req)
if err != nil {
return nil, &UpstreamError{Err: err}
}
@@ -170,7 +170,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
req2.Header.Set("Accept", accept)
}
}
resp, err = upstreamClient.Do(req2)
resp, err = clientForRemote(remote).Do(req2)
if err != nil {
return nil, &UpstreamError{Err: err}
}
@@ -302,7 +302,7 @@ func (e *Engine) checkUpstream(ctx context.Context, remote models.Remote, path,
}
}
resp, err := upstreamClient.Do(req)
resp, err := clientForRemote(remote).Do(req)
if err != nil {
return false, &UpstreamError{Err: err}
}
@@ -392,7 +392,7 @@ func fetchBearerToken(ctx context.Context, wwwAuth string, remote models.Remote)
req.SetBasicAuth(remote.Username, remote.Password)
}
resp, err := upstreamClient.Do(req)
resp, err := clientForRemote(remote).Do(req)
if err != nil {
return "", err
}
+74 -21
View File
@@ -3,28 +3,81 @@ package proxy
import (
"net"
"net/http"
"sync"
"time"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// upstreamClient is the shared HTTP client for all upstream requests.
//
// It deliberately sets no overall Client.Timeout: the proxy streams
// arbitrarily large artifacts and the total time is bounded by the request
// context instead. Instead it constrains the phases that must never hang
// connect, TLS handshake, and time-to-first-response-header — so a slow or
// wedged upstream cannot pin a goroutine and connection indefinitely.
var upstreamClient = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 10 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
},
// Default upstream timeouts. A remote may override any of these; a zero
// override falls back to the default here. There is deliberately no overall
// Client.Timeout: the proxy streams arbitrarily large artifacts and total time
// is bounded by the request context instead. We only constrain the phases that
// must never hang — connect, TLS handshake, and time-to-first-response-header
// so a slow or wedged upstream cannot pin a goroutine and connection.
const (
defaultDialTimeout = 10 * time.Second
defaultTLSTimeout = 10 * time.Second
defaultResponseHeaderTimeout = 30 * time.Second
)
type clientKey struct {
dial time.Duration
tls time.Duration
respHeader time.Duration
}
var (
clientCacheMu sync.Mutex
clientCache = map[clientKey]*http.Client{}
)
// upstreamClientFor returns an HTTP client configured with the given timeouts,
// reusing a cached client (and its connection pool) for identical timeout sets.
// Zero values fall back to the defaults.
func upstreamClientFor(dial, tls, respHeader time.Duration) *http.Client {
if dial <= 0 {
dial = defaultDialTimeout
}
if tls <= 0 {
tls = defaultTLSTimeout
}
if respHeader <= 0 {
respHeader = defaultResponseHeaderTimeout
}
key := clientKey{dial: dial, tls: tls, respHeader: respHeader}
clientCacheMu.Lock()
defer clientCacheMu.Unlock()
if c, ok := clientCache[key]; ok {
return c
}
c := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: dial,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: tls,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: respHeader,
},
}
clientCache[key] = c
return c
}
// clientForRemote returns the upstream client for a remote, applying its
// per-remote timeout overrides (in seconds) on top of the defaults.
func clientForRemote(remote models.Remote) *http.Client {
return upstreamClientFor(
time.Duration(remote.UpstreamDialTimeout)*time.Second,
time.Duration(remote.UpstreamTLSTimeout)*time.Second,
time.Duration(remote.UpstreamResponseHeaderTimeout)*time.Second,
)
}