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