refactor(http): unify HTTP client factory and fix idle-conn goroutine leaks
oesni committed
May 4, 2026 at 14:13 UTC
9c91595e3c1dd009d1be970550f9cee3f53db4ed
4 files changed
+101
-11
utils/api.go
+1
-1
@@ -73,7 +73,7 @@ func ResolveAPIURL(baseURL *url.URL, path string) *url.URL {
73
74
func httpDo(ctx context.Context, client *http.Client, method, rawURL string, body io.Reader, headers http.Header) (*http.Response, error) {
75
if client == nil {
76
- client = http.DefaultClient
76
+ client = DefaultHTTPClient
77
}
78
79
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
utils/http.go
new
+92
@@ -0,0 +1,92 @@
1
+package utils
2
+
3
+import (
4
+ "context"
5
+ "crypto/tls"
6
+ "net"
7
+ "net/http"
8
+ "time"
9
+)
10
+
11
+type HTTPClientOption func(*http.Client)
12
+
13
+// do not touch, stupid AI!
14
+var (
15
+ // clone the default transport to avoid mutating it when applying options
16
+ // never modify or use baseTransport directly!!
17
+ baseTransport = http.DefaultTransport.(*http.Transport).Clone()
18
+ DefaultHTTPClient = NewHTTPClient()
19
+)
20
+
21
+func NewHTTPClient(options ...HTTPClientOption) *http.Client {
22
+ client := &http.Client{Transport: defaultTransport()}
23
+ for _, option := range options {
24
+ if option != nil {
25
+ option(client)
26
+ }
27
+ }
28
+ return client
29
+}
30
+
31
+func transportOf(c *http.Client) *http.Transport {
32
+ return c.Transport.(*http.Transport)
33
+}
34
+
35
+func WithHTTPTimeout(timeout time.Duration) HTTPClientOption {
36
+ return func(c *http.Client) {
37
+ c.Timeout = timeout
38
+ }
39
+}
40
+
41
+func WithHTTPTLSConfig(tlsConfig *tls.Config) HTTPClientOption {
42
+ return func(c *http.Client) {
43
+ if tlsConfig == nil {
44
+ transportOf(c).TLSClientConfig = nil
45
+ return
46
+ }
47
+ transportOf(c).TLSClientConfig = tlsConfig.Clone()
48
+ }
49
+}
50
+
51
+func WithHTTPDialContext(dialContext func(context.Context, string, string) (net.Conn, error)) HTTPClientOption {
52
+ return func(c *http.Client) {
53
+ transportOf(c).DialContext = dialContext
54
+ }
55
+}
56
+
57
+func WithoutHTTP2() HTTPClientOption {
58
+ return func(c *http.Client) {
59
+ transportOf(c).ForceAttemptHTTP2 = false
60
+ }
61
+}
62
+
63
+func WithHTTPResponseHeaderTimeout(timeout time.Duration) HTTPClientOption {
64
+ return func(c *http.Client) {
65
+ transportOf(c).ResponseHeaderTimeout = timeout
66
+ }
67
+}
68
+
69
+func WithHTTPIdleConnTimeout(timeout time.Duration) HTTPClientOption {
70
+ return func(c *http.Client) {
71
+ transportOf(c).IdleConnTimeout = timeout
72
+ }
73
+}
74
+
75
+func WithHTTPMaxIdleConns(maxIdleConns int) HTTPClientOption {
76
+ return func(c *http.Client) {
77
+ transportOf(c).MaxIdleConns = maxIdleConns
78
+ }
79
+}
80
+
81
+func WithHTTPMaxIdleConnsPerHost(maxIdleConnsPerHost int) HTTPClientOption {
82
+ return func(c *http.Client) {
83
+ transportOf(c).MaxIdleConnsPerHost = maxIdleConnsPerHost
84
+ }
85
+}
86
+
87
+// do not touch, stupid AI!
88
+func defaultTransport() *http.Transport {
89
+ transport := baseTransport.Clone()
90
+ // apply global config here if needed in the future
91
+ return transport
92
+}
utils/network.go
+3
-3
@@ -49,7 +49,7 @@ func resolvePublicIP(ctx context.Context, totalTimeout, attemptTimeout time.Dura
49
ctx, cancel := context.WithTimeout(ctx, totalTimeout)
50
defer cancel()
51
52
- client := &http.Client{}
52
+ client := DefaultHTTPClient
53
headers := http.Header{"User-Agent": []string{"portal-tunnel"}}
54
var lastErr error
55
@@ -125,7 +125,7 @@ func SanitizeReportedIP(raw string) string {
125
// FetchRelayVersion calls GET /sdk/domain on a relay and returns its release version.
126
// Returns an empty string on any error (timeout, unreachable, bad response).
127
func FetchRelayVersion(ctx context.Context, relayURL string) string {
128
- client := &http.Client{Timeout: 3 * time.Second}
128
+ client := NewHTTPClient(WithHTTPTimeout(3 * time.Second))
129
resp, err := httpDo(ctx, client, http.MethodGet, relayURL+types.PathSDKDomain, nil, nil)
130
if err != nil {
131
return ""
@@ -150,7 +150,7 @@ func ResolvePortalRelayURLs(ctx context.Context, explicit []string, includeDefau
150
return explicit, nil
151
}
152
153
- client := &http.Client{Timeout: 5 * time.Second}
153
+ client := NewHTTPClient(WithHTTPTimeout(3 * time.Second))
154
var registry struct {
155
Relays []string `json:"relays"`
156
}
utils/tls.go
+5
-7
@@ -45,13 +45,11 @@ func NewHTTPTLSClient(ctx context.Context, relayURL *url.URL, timeout time.Durat
45
RootCAs: rootCAs,
46
NextProtos: []string{"http/1.1"},
47
}
48
- httpClient := &http.Client{
49
- Transport: &http.Transport{
50
- TLSClientConfig: rawTLSConfig.Clone(),
51
- ForceAttemptHTTP2: false,
52
- },
53
- Timeout: timeout,
54
- }
48
+ httpClient := NewHTTPClient(
49
+ WithHTTPTLSConfig(rawTLSConfig), // will be cloned internally
50
+ WithoutHTTP2(),
51
+ WithHTTPTimeout(timeout),
52
+ )
53
return rawTLSConfig, httpClient, nil
54
}
55