@@ -14,6 +14,7 @@ import (
1414 "os/signal"
1515 "strconv"
1616 "strings"
17+ "sync/atomic"
1718 "syscall"
1819 "time"
1920
@@ -50,6 +51,13 @@ type Config struct {
5051 // Force use of unencrypted ws:// protocol instead of wss://
5152 NoWSS bool
5253 Insecure bool
54+ // MaxConnections allows tuning the maximum concurrent connections per host.
55+ // Default: 50 concurrent connections
56+ // This can be increased for high-volume testing scenarios where the local
57+ // endpoint can handle more concurrent requests.
58+ // Example: Set to 100+ when load testing with many parallel webhooks.
59+ // Warning: Setting this too high may cause resource exhaustion.
60+ MaxConnections int
5361}
5462
5563// A Proxy opens a websocket connection with Hookdeck, listens for incoming
@@ -60,6 +68,10 @@ type Proxy struct {
6068 connections []* hookdecksdk.Connection
6169 webSocketClient * websocket.Client
6270 connectionTimer * time.Timer
71+ httpClient * http.Client
72+ transport * http.Transport
73+ activeRequests int32
74+ maxConnWarned bool // Track if we've warned about connection limit
6375}
6476
6577func withSIGTERMCancel (ctx context.Context , onCancel func ()) context.Context {
@@ -252,21 +264,42 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
252264 fmt .Println (webhookEvent .Body .Request .DataString )
253265 } else {
254266 url := p .cfg .URL .Scheme + "://" + p .cfg .URL .Host + p .cfg .URL .Path + webhookEvent .Body .Path
255- tr := & http.Transport {
256- TLSClientConfig : & tls.Config {InsecureSkipVerify : p .cfg .Insecure },
257- }
258267
268+ // Create request with context for timeout control
259269 timeout := webhookEvent .Body .Request .Timeout
260270 if timeout == 0 {
261271 timeout = 1000 * 30
262272 }
263273
264- client := & http.Client {
265- Timeout : time .Duration (timeout ) * time .Millisecond ,
266- Transport : tr ,
274+ // Track active requests
275+ atomic .AddInt32 (& p .activeRequests , 1 )
276+ defer atomic .AddInt32 (& p .activeRequests , - 1 )
277+
278+ activeCount := atomic .LoadInt32 (& p .activeRequests )
279+
280+ // Calculate warning thresholds proportionally to max connections
281+ maxConns := int32 (p .transport .MaxConnsPerHost )
282+ warningThreshold := int32 (float64 (maxConns ) * 0.8 ) // Warn at 80% capacity
283+ resetThreshold := int32 (float64 (maxConns ) * 0.6 ) // Reset warning at 60% capacity
284+
285+ // Warn when approaching connection limit
286+ if activeCount > warningThreshold && ! p .maxConnWarned {
287+ p .maxConnWarned = true
288+ color := ansi .Color (os .Stdout )
289+ fmt .Printf ("\n %s High connection load detected (%d active requests)\n " ,
290+ color .Yellow ("⚠ WARNING:" ), activeCount )
291+ fmt .Printf (" The CLI is limited to %d concurrent connections per host.\n " , p .transport .MaxConnsPerHost )
292+ fmt .Printf (" Consider reducing request rate or increasing connection limit.\n " )
293+ fmt .Printf (" Run with --max-connections=%d to increase the limit.\n \n " , maxConns * 2 )
294+ } else if activeCount < resetThreshold && p .maxConnWarned {
295+ // Reset warning flag when load decreases
296+ p .maxConnWarned = false
267297 }
268298
269- req , err := http .NewRequest (webhookEvent .Body .Request .Method , url , nil )
299+ ctx , cancel := context .WithTimeout (context .Background (), time .Duration (timeout )* time .Millisecond )
300+ defer cancel ()
301+
302+ req , err := http .NewRequestWithContext (ctx , webhookEvent .Body .Request .Method , url , nil )
270303 if err != nil {
271304 fmt .Printf ("Error: %s\n " , err )
272305 return
@@ -286,13 +319,13 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
286319 req .Body = ioutil .NopCloser (strings .NewReader (webhookEvent .Body .Request .DataString ))
287320 req .ContentLength = int64 (len (webhookEvent .Body .Request .DataString ))
288321
289- res , err := client .Do (req )
290-
322+ res , err := p .httpClient .Do (req )
291323 if err != nil {
292324 color := ansi .Color (os .Stdout )
293325 localTime := time .Now ().Format (timeLayout )
294326
295- errStr := fmt .Sprintf ("%s [%s] Failed to %s: %v" ,
327+ // Use the original error message
328+ errStr := fmt .Sprintf ("%s [%s] Failed to %s: %s" ,
296329 color .Faint (localTime ),
297330 color .Red ("ERROR" ),
298331 webhookEvent .Body .Request .Method ,
@@ -309,7 +342,11 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
309342 },
310343 }})
311344 } else {
345+ // Process the response (this reads the entire body)
312346 p .processEndpointResponse (webhookEvent , res )
347+
348+ // Close the body - connection can be reused since body was fully read
349+ res .Body .Close ()
313350 }
314351 }
315352}
@@ -366,10 +403,34 @@ func New(cfg *Config, connections []*hookdecksdk.Connection) *Proxy {
366403 cfg .Log = & log.Logger {Out : ioutil .Discard }
367404 }
368405
406+ // Default to 50 connections if not specified
407+ maxConns := cfg .MaxConnections
408+ if maxConns <= 0 {
409+ maxConns = 50
410+ }
411+
412+ // Create a shared HTTP transport with connection pooling
413+ tr := & http.Transport {
414+ TLSClientConfig : & tls.Config {InsecureSkipVerify : cfg .Insecure },
415+ // Connection pool settings - sensible defaults for typical usage
416+ MaxIdleConns : 20 , // Total idle connections across all hosts
417+ MaxIdleConnsPerHost : 10 , // Keep some idle connections for reuse
418+ IdleConnTimeout : 30 * time .Second , // Clean up idle connections
419+ DisableKeepAlives : false ,
420+ // Limit concurrent connections to prevent resource exhaustion
421+ MaxConnsPerHost : maxConns , // User-configurable (default: 50)
422+ ResponseHeaderTimeout : 60 * time .Second ,
423+ }
424+
369425 p := & Proxy {
370426 cfg : cfg ,
371427 connections : connections ,
372428 connectionTimer : time .NewTimer (0 ), // Defaults to no delay
429+ transport : tr ,
430+ httpClient : & http.Client {
431+ Transport : tr ,
432+ // Timeout is controlled per-request via context in processAttempt
433+ },
373434 }
374435
375436 return p
0 commit comments