refactor(http): migrate to utils.NewHTTPClient and stop recreating clients

oesni committed May 4, 2026 at 14:13 UTC e96b5c08817d577a363e9322cfe742782365640a
7 files changed +61 -51
cmd/portal-tunnel/agent/control.go
+3 -1
@@ -18,6 +18,8 @@ const (
18 endpointFilename = "agent-endpoint.json"
19 )
20
21 +var controlHTTPClient = utils.NewHTTPClient(utils.WithHTTPTimeout(5 * time.Second))
22 +
23 type endpoint struct {
24 ControlAddr string `json:"control_addr"`
25 Token string `json:"token"`
@@ -209,5 +211,5 @@ func controlRequest(ctx context.Context, stateDir, method, path string, payload
211 return err
212 }
213 headers := http.Header{"Authorization": []string{"Bearer " + endpoint.Token}}
212 - return utils.HTTPDoAPIPath(ctx, &http.Client{Timeout: 5 * time.Second}, baseURL, method, path, payload, headers, out)
214 + return utils.HTTPDoAPIPath(ctx, controlHTTPClient, baseURL, method, path, payload, headers, out)
215 }
cmd/portal-tunnel/installer/update.go
+13 -12
@@ -15,10 +15,20 @@ import (
15 "time"
16
17 "github.com/gosuda/portal-tunnel/v2/types"
18 + "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 const updateCheckInterval = 24 * time.Hour
22
23 +var updateCheckClient = utils.NewHTTPClient(
24 + utils.WithHTTPTimeout(10*time.Second),
25 + utils.WithHTTPCheckRedirect(func(req *http.Request, via []*http.Request) error {
26 + return http.ErrUseLastResponse
27 + }),
28 +)
29 +
30 +var updateDownloadClient = utils.NewHTTPClient(utils.WithHTTPTimeout(120 * time.Second))
31 +
32 func StartUpdateCheck(currentVersion string) {
33 binURL, _, ok := assetURLs("")
34 if !ok {
@@ -27,16 +37,9 @@ func StartUpdateCheck(currentVersion string) {
37
38 go func() {
39 for {
30 - client := &http.Client{
31 - Timeout: 10 * time.Second,
32 - CheckRedirect: func(req *http.Request, via []*http.Request) error {
33 - return http.ErrUseLastResponse
34 - },
35 - }
36 -
40 req, err := http.NewRequestWithContext(context.Background(), http.MethodHead, binURL, nil)
41 if err == nil {
39 - resp, err := client.Do(req)
42 + resp, err := updateCheckClient.Do(req)
43 if err == nil {
44 location := resp.Header.Get("Location")
45 _ = resp.Body.Close()
@@ -79,14 +82,12 @@ func UpdateCurrentBinary(version string) error {
82 }
83 defer func() { _ = os.Remove(tmpFile.Name()) }()
84
82 - client := &http.Client{Timeout: 120 * time.Second}
83 -
85 req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, binURL, nil)
86 if err != nil {
87 _ = tmpFile.Close()
88 return fmt.Errorf("failed to build binary request: %w", err)
89 }
89 - resp, err := client.Do(req)
90 + resp, err := updateDownloadClient.Do(req)
91 if err != nil {
92 _ = tmpFile.Close()
93 return fmt.Errorf("failed to download binary: %w", err)
@@ -115,7 +116,7 @@ func UpdateCurrentBinary(version string) error {
116 if err != nil {
117 return fmt.Errorf("failed to build checksum request: %w", err)
118 }
118 - resp, err = client.Do(req)
119 + resp, err = updateDownloadClient.Do(req)
120 if err != nil {
121 return fmt.Errorf("failed to download checksum: %w", err)
122 }
cmd/relay-server/thumbnail.go
+5 -1
@@ -14,8 +14,12 @@ import (
14 "github.com/go-rod/rod"
15 "github.com/go-rod/rod/lib/proto"
16 "github.com/rs/zerolog/log"
17 +
18 + "github.com/gosuda/portal-tunnel/v2/utils"
19 )
20
21 +var thumbnailHTTPClient = utils.NewHTTPClient(utils.WithHTTPTimeout(5 * time.Second))
22 +
23 const (
24 thumbnailViewportWidth = 1280
25 thumbnailViewportHeight = 720
@@ -153,7 +157,7 @@ func (s *thumbnailService) resolveCDPWebSocketURL() (string, error) {
157 }
158 req.Host = "127.0.0.1" // headless-shell rejects non-IP Host headers
159
156 - resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
160 + resp, err := thumbnailHTTPClient.Do(req)
161 if err != nil {
162 return "", fmt.Errorf("query /json/version: %w", err)
163 }
portal/discovery/refresher.go
+9 -22
@@ -35,16 +35,14 @@ type Refresher struct {
35 func NewRefresher(relaySet *RelaySet, overlay OverlayRuntime) *Refresher {
36 return &Refresher{
37 relaySet: relaySet,
38 - httpClient: &http.Client{
39 - Transport: &http.Transport{
40 - TLSClientConfig: &tls.Config{
41 - MinVersion: tls.VersionTLS12,
42 - NextProtos: []string{"http/1.1"},
43 - },
44 - ForceAttemptHTTP2: false,
45 - },
46 - Timeout: defaultRequestTimeout,
47 - },
38 + httpClient: utils.NewHTTPClient(
39 + utils.WithHTTPTLSConfig(&tls.Config{
40 + MinVersion: tls.VersionTLS12,
41 + NextProtos: []string{"http/1.1"},
42 + }),
43 + utils.WithoutHTTP2(),
44 + utils.WithHTTPTimeout(defaultRequestTimeout),
45 + ),
46 overlay: overlay,
47 directRecoveryFailures: defaultRecoveryFailures,
48 lastAnnounceSuccess: make(map[string]bool),
@@ -78,17 +76,6 @@ func (r *Refresher) announceSelf(ctx context.Context, descriptor types.RelayDesc
76 ProtocolVersion: types.DiscoveryVersion,
77 Descriptor: descriptor,
78 }
81 - httpClient := &http.Client{
82 - Transport: &http.Transport{
83 - TLSClientConfig: &tls.Config{
84 - MinVersion: tls.VersionTLS12,
85 - NextProtos: []string{"http/1.1"},
86 - },
87 - ForceAttemptHTTP2: false,
88 - },
89 - Timeout: defaultRequestTimeout,
90 - }
91 - defer httpClient.CloseIdleConnections()
79
80 for _, relayURL := range r.relaySet.BootstrapRelayURLs() {
81 if relayURL == descriptor.APIHTTPSAddr {
@@ -105,7 +92,7 @@ func (r *Refresher) announceSelf(ctx context.Context, descriptor types.RelayDesc
92 continue
93 }
94
108 - if err := utils.HTTPDoAPIPath(ctx, httpClient, baseURL, http.MethodPost, types.PathDiscoveryAnnounce, req, nil, nil); err != nil {
95 + if err := utils.HTTPDoAPIPath(ctx, r.httpClient, baseURL, http.MethodPost, types.PathDiscoveryAnnounce, req, nil, nil); err != nil {
96 if ctx.Err() != nil {
97 return ctx.Err()
98 }
portal/overlay/overlay.go
+10 -10
@@ -100,16 +100,16 @@ func NewOverlay(cfg Config, handler http.Handler) (*Overlay, error) {
100 ReadHeaderTimeout: 10 * time.Second,
101 }
102
103 - transport := &http.Transport{
104 - DialContext: stack.DialContext,
105 - TLSHandshakeTimeout: 10 * time.Second,
106 - MaxIdleConns: 100,
107 - IdleConnTimeout: 90 * time.Second,
108 - ResponseHeaderTimeout: 30 * time.Second,
109 - ExpectContinueTimeout: 1 * time.Second,
110 - ForceAttemptHTTP2: false,
111 - }
112 - client := &http.Client{Transport: transport}
103 + client := utils.NewHTTPClient(
104 + utils.WithHTTPDialContext(stack.DialContext),
105 + utils.WithHTTPTLSHandshakeTimeout(10*time.Second),
106 + utils.WithHTTPMaxIdleConns(100),
107 + utils.WithHTTPIdleConnTimeout(90*time.Second),
108 + utils.WithHTTPResponseHeaderTimeout(30*time.Second),
109 + utils.WithHTTPExpectContinueTimeout(1*time.Second),
110 + utils.WithoutHTTP2(),
111 + )
112 + transport := client.Transport.(*http.Transport)
113
114 publicCfg := cfg.Copy()
115 publicCfg.PrivateKey = ""
portal/server_test.go
+3 -5
@@ -32,11 +32,9 @@ func tempIdentityPath(t *testing.T) string {
32
33 func newTestClient(t *testing.T, cancel context.CancelFunc, server *Server) *http.Client {
34 t.Helper()
35 - client := &http.Client{
36 - Transport: &http.Transport{
37 - TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
38 - },
39 - }
35 + client := utils.NewHTTPClient(
36 + utils.WithHTTPTLSConfig(&tls.Config{InsecureSkipVerify: true}),
37 + )
38 t.Cleanup(func() {
39 client.CloseIdleConnections()
40 cancel()
utils/http.go
+18
@@ -84,6 +84,24 @@ func WithHTTPMaxIdleConnsPerHost(maxIdleConnsPerHost int) HTTPClientOption {
84 }
85 }
86
87 +func WithHTTPTLSHandshakeTimeout(timeout time.Duration) HTTPClientOption {
88 + return func(c *http.Client) {
89 + transportOf(c).TLSHandshakeTimeout = timeout
90 + }
91 +}
92 +
93 +func WithHTTPExpectContinueTimeout(timeout time.Duration) HTTPClientOption {
94 + return func(c *http.Client) {
95 + transportOf(c).ExpectContinueTimeout = timeout
96 + }
97 +}
98 +
99 +func WithHTTPCheckRedirect(checkRedirect func(req *http.Request, via []*http.Request) error) HTTPClientOption {
100 + return func(c *http.Client) {
101 + c.CheckRedirect = checkRedirect
102 + }
103 +}
104 +
105 // do not touch, stupid AI!
106 func defaultTransport() *http.Transport {
107 transport := baseTransport.Clone()