Skip to content

Commit 323e91d

Browse files
committed
perf: reuse HTTP clients, eliminate memory-heavy DumpResponse, and clean up error noise
- Add HTTP client cache (sync.Map) keyed by proxy+timeout+redirect config, enabling TCP connection pooling across all requests instead of creating a new http.Client + http.Transport per request - Replace httputil.DumpResponse(res, true) with io.Copy(io.Discard, res.Body) to count response size without loading entire bodies into memory - Remove Close: true from http.Request to allow keep-alive connection reuse - Add parseRawURL fallback for non-standard URL encodings (e.g., %u002f IIS-style unicode escapes) that url.Parse rejects - Suppress transient error logs in non-verbose mode (logVerbose guard) - Remove deprecated rand.Seed() calls (auto-seeded since Go 1.20) - Add context.WithTimeout to curl subprocess to prevent indefinite hangs
1 parent 7bbc033 commit 323e91d

2 files changed

Lines changed: 175 additions & 99 deletions

File tree

cmd/api.go

Lines changed: 125 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import (
44
"bufio"
55
"crypto/tls"
66
"fmt"
7+
"io"
78
"log"
89
"net"
910
"net/http"
10-
"net/http/httputil"
1111
"net/url"
1212
"os"
1313
"strings"
14+
"sync"
1415
"time"
1516

1617
"github.com/fatih/color"
@@ -19,6 +20,61 @@ import (
1920
// ErrRateLimited is returned when the server responds with HTTP 429.
2021
var ErrRateLimited = fmt.Errorf("rate limited (HTTP 429)")
2122

23+
// clientCacheKey identifies a unique HTTP client configuration.
24+
type clientCacheKey struct {
25+
proxy string
26+
timeout int
27+
redirect bool
28+
}
29+
30+
var clientCache sync.Map
31+
32+
// getClient returns a cached HTTP client for the given configuration,
33+
// creating one if needed. This enables connection pooling across requests.
34+
func getClient(proxy *url.URL, timeout int, redirect bool) *http.Client {
35+
proxyStr := ""
36+
if proxy != nil {
37+
proxyStr = proxy.String()
38+
}
39+
key := clientCacheKey{proxyStr, timeout, redirect}
40+
41+
if v, ok := clientCache.Load(key); ok {
42+
return v.(*http.Client)
43+
}
44+
45+
timeoutDuration := time.Duration(timeout) * time.Millisecond
46+
transport := &http.Transport{
47+
Proxy: http.ProxyURL(proxy),
48+
TLSClientConfig: &tls.Config{
49+
InsecureSkipVerify: true,
50+
},
51+
DialContext: (&net.Dialer{
52+
Timeout: timeoutDuration,
53+
KeepAlive: 30 * time.Second,
54+
}).DialContext,
55+
MaxIdleConns: 100,
56+
MaxIdleConnsPerHost: 10,
57+
IdleConnTimeout: 90 * time.Second,
58+
TLSHandshakeTimeout: timeoutDuration,
59+
ResponseHeaderTimeout: timeoutDuration,
60+
ExpectContinueTimeout: 1 * time.Second,
61+
}
62+
63+
client := &http.Client{
64+
Transport: transport,
65+
Timeout: timeoutDuration,
66+
}
67+
68+
if !redirect {
69+
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
70+
return http.ErrUseLastResponse
71+
}
72+
}
73+
74+
clientCache.Store(key, client)
75+
return client
76+
}
77+
2278
// parseFile reads a file given its filename and returns a list containing each of its lines.
2379
func parseFile(filename string) ([]string, error) {
2480
file, err := os.Open(filename)
@@ -56,7 +112,8 @@ type header struct {
56112
// requestWithRetry makes an HTTP request with retry logic and exponential backoff.
57113
// It retries up to maxRetries times on transient errors (timeouts, connection errors).
58114
// On HTTP 429, it retries with backoff if rateLimit is false; returns ErrRateLimited if rateLimit is true.
59-
func requestWithRetry(method, uri string, headers []header, proxy *url.URL, rateLimit bool, timeout int, redirect bool) (int, []byte, error) {
115+
// Returns (statusCode, responseBodySize, error).
116+
func requestWithRetry(method, uri string, headers []header, proxy *url.URL, rateLimit bool, timeout int, redirect bool) (int, int, error) {
60117
const maxRetries = 2
61118
var lastErr error
62119

@@ -66,30 +123,25 @@ func requestWithRetry(method, uri string, headers []header, proxy *url.URL, rate
66123
time.Sleep(backoff)
67124
}
68125

69-
statusCode, resp, err := request(method, uri, headers, proxy, timeout, redirect)
126+
statusCode, respSize, err := request(method, uri, headers, proxy, timeout, redirect)
70127
if err == nil {
71-
// Handle rate limiting
72128
if statusCode == 429 {
73129
if rateLimit {
74-
return statusCode, resp, ErrRateLimited
130+
return statusCode, respSize, ErrRateLimited
75131
}
76132
lastErr = fmt.Errorf("HTTP 429 rate limited on attempt %d", attempt+1)
77133
continue
78134
}
79-
return statusCode, resp, nil
135+
return statusCode, respSize, nil
80136
}
81137

82138
lastErr = err
83-
// Only retry on transient errors (timeouts, connection refused, etc.)
84139
if !isTransientError(err) {
85-
return 0, nil, err
86-
}
87-
if attempt < maxRetries {
88-
log.Printf("[!] Transient error (attempt %d/%d): %v", attempt+1, maxRetries+1, err)
140+
return 0, 0, err
89141
}
90142
}
91143

92-
return 0, nil, fmt.Errorf("request failed after %d attempts: %w", maxRetries+1, lastErr)
144+
return 0, 0, fmt.Errorf("request failed after %d attempts: %w", maxRetries+1, lastErr)
93145
}
94146

95147
// isTransientError returns true for errors that are likely transient and worth retrying.
@@ -115,7 +167,8 @@ func isTransientError(err error) bool {
115167
}
116168

117169
// request makes a single HTTP request using headers `headers` and proxy `proxy`.
118-
func request(method, uri string, headers []header, proxy *url.URL, timeout int, redirect bool) (int, []byte, error) {
170+
// Returns (statusCode, responseBodySize, error).
171+
func request(method, uri string, headers []header, proxy *url.URL, timeout int, redirect bool) (int, int, error) {
119172
if method == "" {
120173
method = "GET"
121174
}
@@ -124,47 +177,26 @@ func request(method, uri string, headers []header, proxy *url.URL, timeout int,
124177
proxy = nil
125178
}
126179

127-
timeoutDuration := time.Duration(timeout) * time.Millisecond
128-
customTransport := &http.Transport{
129-
Proxy: http.ProxyURL(proxy),
130-
TLSClientConfig: &tls.Config{
131-
InsecureSkipVerify: true,
132-
},
133-
DialContext: (&net.Dialer{
134-
Timeout: timeoutDuration,
135-
KeepAlive: 30 * time.Second,
136-
}).DialContext,
137-
MaxIdleConns: 100,
138-
IdleConnTimeout: 90 * time.Second,
139-
TLSHandshakeTimeout: timeoutDuration,
140-
ResponseHeaderTimeout: timeoutDuration,
141-
ExpectContinueTimeout: 1 * time.Second,
142-
}
143-
144-
client := &http.Client{
145-
Transport: customTransport,
146-
Timeout: timeoutDuration,
147-
}
148-
149-
if !redirect {
150-
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
151-
return http.ErrUseLastResponse
152-
}
153-
}
180+
client := getClient(proxy, timeout, redirect)
154181

155182
parsedURL, err := url.Parse(uri)
156183
if err != nil || parsedURL == nil || parsedURL.Scheme == "" || parsedURL.Host == "" {
157-
return 0, nil, fmt.Errorf("invalid URL: %q", uri)
184+
// Fallback for non-standard encoding (e.g., %u002f unicode escapes)
185+
// that url.Parse rejects. Extract scheme/host manually and preserve
186+
// the raw path so the server receives it as-is.
187+
parsedURL, err = parseRawURL(uri)
188+
if err != nil {
189+
return 0, 0, fmt.Errorf("invalid URL: %q", uri)
190+
}
191+
} else {
192+
parsedURL.RawPath = parsedURL.EscapedPath()
158193
}
159194

160-
parsedURL.RawPath = parsedURL.EscapedPath()
161-
162195
req := &http.Request{
163196
Method: method,
164197
Host: parsedURL.Host,
165198
URL: parsedURL,
166199
Header: make(http.Header),
167-
Close: true,
168200
}
169201

170202
for _, header := range headers {
@@ -173,20 +205,18 @@ func request(method, uri string, headers []header, proxy *url.URL, timeout int,
173205

174206
res, err := client.Do(req)
175207
if err != nil {
176-
return 0, nil, err
208+
return 0, 0, err
177209
}
178210
defer func() {
179211
if cerr := res.Body.Close(); cerr != nil {
180212
log.Printf("[!] Error closing response body: %v", cerr)
181213
}
182214
}()
183215

184-
resp, err := httputil.DumpResponse(res, true)
185-
if err != nil {
186-
return 0, nil, err
187-
}
216+
// Read and discard body to get size and allow connection reuse
217+
bodySize, _ := io.Copy(io.Discard, res.Body)
188218

189-
return res.StatusCode, resp, nil
219+
return res.StatusCode, int(bodySize), nil
190220
}
191221

192222
// loadFlagsFromRequestFile parse an HTTP request and configure the necessary flags for an execution
@@ -258,13 +288,13 @@ func runAutocalibrate(options RequestOptions) (int, int) {
258288
var lastStatusCode int
259289
for _, path := range calibrationPaths {
260290
calibrationURI := baseURI + path
261-
statusCode, response, err := requestWithRetry("GET", calibrationURI, options.headers, options.proxy, options.rateLimit, options.timeout, options.redirect)
291+
statusCode, respSize, err := requestWithRetry("GET", calibrationURI, options.headers, options.proxy, options.rateLimit, options.timeout, options.redirect)
262292
if err != nil {
263293
log.Printf("[!] Error during calibration request (%s): %v\n", path, err)
264294
continue
265295
}
266296
lastStatusCode = statusCode
267-
samples = append(samples, len(response))
297+
samples = append(samples, respSize)
268298
}
269299

270300
if len(samples) == 0 {
@@ -303,3 +333,46 @@ func runAutocalibrate(options RequestOptions) (int, int) {
303333

304334
return avgCl, tolerance
305335
}
336+
337+
// parseRawURL extracts scheme, host, and raw path from a URI without decoding
338+
// percent-encoded sequences. This allows non-standard encodings like %u002f
339+
// (IIS-style Unicode escapes) to be sent to the server as-is.
340+
func parseRawURL(rawURI string) (*url.URL, error) {
341+
idx := strings.Index(rawURI, "://")
342+
if idx < 0 {
343+
return nil, fmt.Errorf("missing scheme")
344+
}
345+
scheme := rawURI[:idx]
346+
if scheme != "http" && scheme != "https" {
347+
return nil, fmt.Errorf("unsupported scheme %q", scheme)
348+
}
349+
rest := rawURI[idx+3:]
350+
351+
slashIdx := strings.Index(rest, "/")
352+
var host, rawPath string
353+
if slashIdx < 0 {
354+
host = rest
355+
rawPath = "/"
356+
} else {
357+
host = rest[:slashIdx]
358+
rawPath = rest[slashIdx:]
359+
}
360+
361+
if host == "" {
362+
return nil, fmt.Errorf("missing host")
363+
}
364+
365+
// Split raw path and query
366+
rawQuery := ""
367+
if qIdx := strings.Index(rawPath, "?"); qIdx >= 0 {
368+
rawQuery = rawPath[qIdx+1:]
369+
rawPath = rawPath[:qIdx]
370+
}
371+
372+
return &url.URL{
373+
Scheme: scheme,
374+
Host: host,
375+
Opaque: rawPath,
376+
RawQuery: rawQuery,
377+
}, nil
378+
}

0 commit comments

Comments
 (0)