diff --git a/go.mod b/go.mod index 90d5344..869bc30 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module nudgebee/forager -go 1.26.9 +go 1.26.10 require ( cloud.google.com/go/auth v0.23.1 diff --git a/pkg/ws/client.go b/pkg/ws/client.go index 6865fe7..2a567e5 100644 --- a/pkg/ws/client.go +++ b/pkg/ws/client.go @@ -4,9 +4,11 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "log/slog" + "net" "net/http" "sync" "time" @@ -119,7 +121,11 @@ func (c *Client) Run(ctx context.Context) error { err := c.connectAndServe(ctx) if err != nil { - c.logger.Error("websocket session ended", "err", err) + if isExpectedDisconnect(err) { + c.logger.Warn("websocket session ended", "err", err) + } else { + c.logger.Error("websocket session ended", "err", err) + } } select { @@ -137,6 +143,25 @@ func (c *Client) Run(ctx context.Context) error { } } +func isExpectedDisconnect(err error) bool { + if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) { + return true + } + var closeErr *websocket.CloseError + if errors.As(err, &closeErr) { + return websocket.IsCloseError(closeErr, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseAbnormalClosure, + ) + } + return websocket.IsCloseError(err, + websocket.CloseNormalClosure, + websocket.CloseGoingAway, + websocket.CloseAbnormalClosure, + ) +} + func (c *Client) connectAndServe(ctx context.Context) error { // Build auth header auth := base64.StdEncoding.EncodeToString([]byte(c.accessKey + ":" + c.accessSecret)) diff --git a/pkg/ws/client_test.go b/pkg/ws/client_test.go new file mode 100644 index 0000000..05d5277 --- /dev/null +++ b/pkg/ws/client_test.go @@ -0,0 +1,110 @@ +package ws + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "testing" + + "github.com/gorilla/websocket" +) + +func TestIsExpectedDisconnect(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "context.Canceled", + err: context.Canceled, + expected: true, + }, + { + name: "wrapped context.Canceled", + err: fmt.Errorf("connection context done: %w", context.Canceled), + expected: true, + }, + { + name: "net.ErrClosed", + err: net.ErrClosed, + expected: true, + }, + { + name: "wrapped net.ErrClosed", + err: fmt.Errorf("socket closed: %w", net.ErrClosed), + expected: true, + }, + { + name: "io.EOF", + err: io.EOF, + expected: true, + }, + { + name: "wrapped io.EOF", + err: fmt.Errorf("read error: %w", io.EOF), + expected: true, + }, + { + name: "websocket CloseNormalClosure", + err: &websocket.CloseError{ + Code: websocket.CloseNormalClosure, + Text: "normal closure", + }, + expected: true, + }, + { + name: "websocket CloseGoingAway", + err: &websocket.CloseError{ + Code: websocket.CloseGoingAway, + Text: "going away", + }, + expected: true, + }, + { + name: "websocket CloseAbnormalClosure", + err: &websocket.CloseError{ + Code: websocket.CloseAbnormalClosure, + Text: "abnormal closure", + }, + expected: true, + }, + { + name: "wrapped CloseAbnormalClosure", + err: fmt.Errorf("read error: %w", &websocket.CloseError{ + Code: websocket.CloseAbnormalClosure, + Text: "unexpected EOF", + }), + expected: true, + }, + { + name: "websocket CloseProtocolError", + err: &websocket.CloseError{ + Code: websocket.CloseProtocolError, + Text: "protocol error", + }, + expected: false, + }, + { + name: "generic unexpected error", + err: errors.New("unexpected database error"), + expected: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + actual := isExpectedDisconnect(tc.err) + if actual != tc.expected { + t.Errorf("isExpectedDisconnect(%v) = %v, want %v", tc.err, actual, tc.expected) + } + }) + } +}