@@ -12,6 +12,7 @@ import (
1212 "sync"
1313 "time"
1414
15+ "github.com/codeGROOVE-dev/retry"
1516 "golang.org/x/net/websocket"
1617)
1718
@@ -36,6 +37,10 @@ const (
3637 // Server ping timing constants.
3738 expectedPingInterval = 54 * time .Second
3839 pingDelayThreshold = 10 * time .Second
40+
41+ // Read timeout for WebSocket operations.
42+ // Set to 90s to be longer than server ping interval (54s) to avoid false timeouts.
43+ readTimeout = 90 * time .Second
3944)
4045
4146// Event represents a webhook event received from the server.
@@ -118,33 +123,28 @@ func New(config Config) (*Client, error) {
118123func (c * Client ) Start (ctx context.Context ) error {
119124 defer close (c .stoppedCh )
120125
121- for {
122- select {
123- case <- ctx . Done ():
124- c . logger . Info ( "Client context cancelled, shutting down" )
125- return ctx . Err ()
126- case <- c . stopCh :
127- c .logger . Info ( "Client stop requested" )
128- return nil
129- default :
130- }
126+ // Create retry options
127+ retryOpts := []retry. Option {
128+ retry . Context ( ctx ),
129+ retry . DelayType ( retry . FullJitterBackoffDelay ),
130+ retry . MaxDelay ( c . config . MaxBackoff ),
131+ retry . OnRetry ( func ( n uint , err error ) {
132+ c .mu . Lock ( )
133+ //nolint:gosec // Retry count will not overflow in practice
134+ c . retries = int ( n )
135+ c . mu . Unlock ()
131136
132- // Connection attempt logging
133- if c .retries == 0 {
134- c .logger .Info ("========================================" )
135- c .logger .Info ("CONNECTING to WebSocket server" , "url" , c .config .ServerURL )
136- c .logger .Info ("========================================" )
137- } else {
138- c .logger .Info ("========================================" )
139- c .logger .Info ("RECONNECTING to WebSocket server" , "url" , c .config .ServerURL , "attempt" , c .retries )
140- c .logger .Info ("========================================" )
141- }
137+ c .logger .Warn (separatorLine )
138+ c .logger .Warn ("WebSocket CONNECTION LOST!" , "error" , err , "events_received" , c .eventCount , "attempt" , n + 1 )
139+ c .logger .Warn (separatorLine )
142140
143- // Try to connect
144- err := c .connect (ctx )
145- // Handle connection result
146- if err != nil {
147- // Check if it's an authentication error - don't retry these
141+ // Notify disconnect callback
142+ if c .config .OnDisconnect != nil {
143+ c .config .OnDisconnect (err )
144+ }
145+ }),
146+ retry .RetryIf (func (err error ) bool {
147+ // Don't retry authentication errors
148148 var authErr * AuthenticationError
149149 if errors .As (err , & authErr ) {
150150 c .logger .Error (separatorLine )
@@ -154,51 +154,63 @@ func (c *Client) Start(ctx context.Context) error {
154154 c .logger .Error ("- Not being a member of the requested organization" )
155155 c .logger .Error ("- Insufficient permissions" )
156156 c .logger .Error (separatorLine )
157- return err
158- }
159-
160- c .logger .Warn (separatorLine )
161- c .logger .Warn ("WebSocket CONNECTION LOST!" , "error" , err , "events_received" , c .eventCount )
162- c .logger .Warn (separatorLine )
163-
164- // Notify disconnect callback
165- if c .config .OnDisconnect != nil {
166- c .config .OnDisconnect (err )
157+ return false
167158 }
168159
169- // Check if reconnection is disabled
160+ // Don't retry if reconnection is disabled
170161 if c .config .NoReconnect {
171- return fmt . Errorf ( "connection failed and reconnection disabled: %w" , err )
162+ return false
172163 }
173164
174- // Check retry limit
175- c .retries ++
176- if c .config .MaxRetries > 0 && c .retries > c .config .MaxRetries {
177- c .logger .Error ("Exceeded maximum retry attempts. Giving up." , "max_retries" , c .config .MaxRetries )
178- return fmt .Errorf ("exceeded maximum retry attempts (%d)" , c .config .MaxRetries )
165+ // Don't retry if stop was requested
166+ select {
167+ case <- c .stopCh :
168+ return false
169+ default :
170+ return true
179171 }
172+ }),
173+ }
180174
181- // Calculate backoff delay
182- delay := time .Duration (c .retries ) * time .Second
183- if delay > c .config .MaxBackoff {
184- delay = c .config .MaxBackoff
185- }
175+ // Configure retry attempts
176+ if c .config .MaxRetries > 0 {
177+ //nolint:gosec // MaxRetries is a user-configured value, overflow not a concern
178+ retryOpts = append (retryOpts , retry .Attempts (uint (c .config .MaxRetries )))
179+ } else {
180+ retryOpts = append (retryOpts , retry .UntilSucceeded ())
181+ }
186182
187- c .logger .Info ("Will attempt to reconnect" , "delay_seconds" , delay .Seconds ())
188- c .logger .Info ("Press Ctrl+C to exit" )
183+ // Use retry library to handle reconnection with exponential backoff and jitter
184+ return retry .Do (func () error {
185+ // Check for early cancellation - don't retry on shutdown
186+ select {
187+ case <- ctx .Done ():
188+ c .logger .Info ("Client context cancelled, shutting down" )
189+ return retry .Unrecoverable (ctx .Err ())
190+ case <- c .stopCh :
191+ c .logger .Info ("Client stop requested" )
192+ return retry .Unrecoverable (errors .New ("stop requested" ))
193+ default :
194+ }
189195
190- // Wait before reconnecting
191- select {
192- case <- time .After (delay ):
193- c .logger .Info ("Reconnection delay elapsed, attempting to reconnect" )
194- continue
195- case <- ctx .Done ():
196- return ctx .Err ()
197- case <- c .stopCh :
198- return nil
199- }
196+ // Connection attempt logging
197+ c .mu .RLock ()
198+ n := c .retries
199+ c .mu .RUnlock ()
200+
201+ if n == 0 {
202+ c .logger .Info ("========================================" )
203+ c .logger .Info ("CONNECTING to WebSocket server" , "url" , c .config .ServerURL )
204+ c .logger .Info ("========================================" )
205+ } else {
206+ c .logger .Info ("========================================" )
207+ c .logger .Info ("RECONNECTING to WebSocket server" , "url" , c .config .ServerURL , "attempt" , n )
208+ c .logger .Info ("========================================" )
200209 }
201- }
210+
211+ // Try to connect - this will run indefinitely if successful
212+ return c .connect (ctx )
213+ }, retryOpts ... )
202214}
203215
204216// Stop gracefully stops the client.
@@ -242,7 +254,10 @@ func (c *Client) connect(ctx context.Context) error {
242254 // Extract status code if present
243255 if strings .Contains (errStr , "403" ) || strings .Contains (errLower , "forbidden" ) {
244256 return & AuthenticationError {
245- message : fmt .Sprintf ("Authentication failed (403 Forbidden): Check your GitHub token and organization membership. Original error: %v" , err ),
257+ message : fmt .Sprintf (
258+ "Authentication failed (403 Forbidden): Check your GitHub token and organization membership. Original error: %v" ,
259+ err ,
260+ ),
246261 }
247262 }
248263 if strings .Contains (errStr , "401" ) || strings .Contains (errLower , "unauthorized" ) {
@@ -320,21 +335,12 @@ func (c *Client) connect(ctx context.Context) error {
320335 }
321336
322337 // Check response type
323- responseType := ""
324- if t , ok := firstResponse [msgTypeField ].(string ); ok {
325- responseType = t
326- }
338+ responseType , _ := firstResponse [msgTypeField ].(string ) //nolint:errcheck // type assertion, not error
327339
328340 // Handle error response
329341 if responseType == "error" {
330- errorCode := ""
331- if code , ok := firstResponse ["error" ].(string ); ok {
332- errorCode = code
333- }
334- message := ""
335- if msg , ok := firstResponse ["message" ].(string ); ok {
336- message = msg
337- }
342+ errorCode , _ := firstResponse ["error" ].(string ) //nolint:errcheck // type assertion, not error
343+ message , _ := firstResponse ["message" ].(string ) //nolint:errcheck // type assertion, not error
338344 c .logger .Error (separatorLine )
339345 c .logger .Error ("SUBSCRIPTION REJECTED BY SERVER!" , "error_code" , errorCode , "message" , message )
340346 c .logger .Error (separatorLine )
@@ -384,7 +390,9 @@ func (c *Client) connect(ctx context.Context) error {
384390 }
385391
386392 // Reset retry counter on successful connection
393+ c .mu .Lock ()
387394 c .retries = 0
395+ c .mu .Unlock ()
388396
389397 // Start ping sender
390398 pingCtx , cancelPing := context .WithCancel (ctx )
@@ -423,10 +431,6 @@ func (c *Client) sendPings(ctx context.Context, ws *websocket.Conn) {
423431
424432// readEvents reads and processes events from the WebSocket with responsive shutdown.
425433func (c * Client ) readEvents (ctx context.Context , ws * websocket.Conn ) error {
426- // Set read timeout to be longer than server ping interval (54s) to avoid false timeouts
427- // Server sends pings every 54s, so we should expect a message at least that often
428- readTimeout := 90 * time .Second
429-
430434 for {
431435 // Check for context cancellation first
432436 select {
@@ -470,10 +474,7 @@ func (c *Client) readEvents(ctx context.Context, ws *websocket.Conn) error {
470474 }
471475
472476 // Check message type
473- responseType := ""
474- if t , ok := response [msgTypeField ].(string ); ok {
475- responseType = t
476- }
477+ responseType , _ := response [msgTypeField ].(string ) //nolint:errcheck // type assertion, not error
477478
478479 // Handle ping messages
479480 if responseType == "ping" {
0 commit comments