Update error handling.

This commit is contained in:
Jay
2026-04-19 15:21:10 -04:00
parent dfd28d65bc
commit 3066802f62
9 changed files with 119 additions and 43 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ var (
ErrConnectionUnavailable = errors.New("connection unavailable") ErrConnectionUnavailable = errors.New("connection unavailable")
) )
func NewConfigError(text string) error { func NewConfigError(err error) error {
return fmt.Errorf("configuration error: %s", text) return fmt.Errorf("configuration error: %w", err)
} }
func NewPoolError(err error) error { func NewPoolError(err error) error {
+1 -1
View File
@@ -125,7 +125,7 @@ func TestRunReader(t *testing.T) {
incomingData <- honeybeetest.MockIncomingData{Err: io.EOF} incomingData <- honeybeetest.MockIncomingData{Err: io.EOF}
err := <-conn.Errors() err := <-conn.Errors()
assert.Equal(t, io.EOF, err) assert.ErrorIs(t, err, io.EOF)
honeybeetest.Eventually(t, func() bool { honeybeetest.Eventually(t, func() bool {
return conn.State() == transport.StateClosed return conn.State() == transport.StateClosed
+1 -1
View File
@@ -86,7 +86,7 @@ func ValidateConnectionConfig(config *ConnectionConfig) error {
} }
if config.Retry.InitialDelay > config.Retry.MaxDelay { if config.Retry.InitialDelay > config.Retry.MaxDelay {
return NewConfigError("initial delay may not exceed maximum delay") return NewConfigError(InvalidDelays)
} }
} }
+18 -11
View File
@@ -91,7 +91,7 @@ func NewConnectionFromSocket(
socket types.Socket, config *ConnectionConfig, logger *slog.Logger, socket types.Socket, config *ConnectionConfig, logger *slog.Logger,
) (*Connection, error) { ) (*Connection, error) {
if socket == nil { if socket == nil {
return nil, NewConnectionError("socket cannot be nil") return nil, NewConnectionError(ErrNilSocket)
} }
if config == nil { if config == nil {
@@ -128,11 +128,11 @@ func (c *Connection) Connect(ctx context.Context) error {
defer c.mu.Unlock() defer c.mu.Unlock()
if c.socket != nil { if c.socket != nil {
return NewConnectionError("connection already has socket") return NewConnectionError(ErrSocketExists)
} }
if c.closed { if c.closed {
return NewConnectionError("connection is closed") return NewConnectionError(ErrConnectionClosed)
} }
if c.logger != nil { if c.logger != nil {
@@ -150,7 +150,7 @@ func (c *Connection) Connect(ctx context.Context) error {
if c.logger != nil { if c.logger != nil {
c.logger.Error("connection failed", "error", err) c.logger.Error("connection failed", "error", err)
} }
return err return NewConnectionError(err)
} }
c.socket = socket c.socket = socket
@@ -217,7 +217,7 @@ func (c *Connection) shutdownSetClosed(wait bool) error {
c.mu.Lock() c.mu.Lock()
if c.closed { if c.closed {
c.mu.Unlock() c.mu.Unlock()
return ErrConnectionClosed return NewConnectionError(ErrConnectionClosed)
} }
c.closed = true c.closed = true
c.state = StateClosed c.state = StateClosed
@@ -277,29 +277,37 @@ func (c *Connection) startReader() {
default: default:
messageType, data, err := c.socket.ReadMessage() messageType, data, err := c.socket.ReadMessage()
if err != nil { if err != nil {
if c.logger != nil { var wrappedErr error
var closeErr *websocket.CloseError var closeErr *websocket.CloseError
if errors.As(err, &closeErr) { if errors.As(err, &closeErr) {
switch closeErr.Code { switch closeErr.Code {
case websocket.CloseNormalClosure, websocket.CloseGoingAway: case websocket.CloseNormalClosure, websocket.CloseGoingAway:
if c.logger != nil {
c.logger.Info("connection closed by peer", c.logger.Info("connection closed by peer",
"code", closeErr.Code, "code", closeErr.Code,
"text", closeErr.Text, "text", closeErr.Text,
) )
}
wrappedErr = fmt.Errorf("%w: %w", ErrPeerClosedClean, err)
default: default:
if c.logger != nil {
c.logger.Error("unexpected close", c.logger.Error("unexpected close",
"code", closeErr.Code, "code", closeErr.Code,
"text", closeErr.Text, "text", closeErr.Text,
) )
} }
wrappedErr = fmt.Errorf("%w: %w", ErrPeerClosedUnexpected, err)
}
} else { } else {
if c.logger != nil {
c.logger.Error("read error", "error", err) c.logger.Error("read error", "error", err)
} }
wrappedErr = fmt.Errorf("%w: %w", ErrReadError, err)
} }
select { select {
case <-c.done: case <-c.done:
case c.errors <- err: case c.errors <- wrappedErr:
} }
return return
} }
@@ -316,7 +324,6 @@ func (c *Connection) startReader() {
} }
} }
}() }()
} }
func (c *Connection) Send(data []byte) error { func (c *Connection) Send(data []byte) error {
@@ -324,7 +331,7 @@ func (c *Connection) Send(data []byte) error {
defer c.writeMu.Unlock() defer c.writeMu.Unlock()
if c.closed { if c.closed {
return ErrConnectionClosed return NewConnectionError(ErrConnectionClosed)
} }
if c.config.WriteTimeout > 0 { if c.config.WriteTimeout > 0 {
@@ -333,7 +340,7 @@ func (c *Connection) Send(data []byte) error {
c.logger.Error("write deadline error", "error", err) c.logger.Error("write deadline error", "error", err)
} }
c.shutdownExternal() c.shutdownExternal()
return fmt.Errorf("failed to set write deadline: %w", err) return NewConnectionError(fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err))
} }
} }
@@ -341,7 +348,7 @@ func (c *Connection) Send(data []byte) error {
if c.logger != nil { if c.logger != nil {
c.logger.Error("write error", "error", err) c.logger.Error("write error", "error", err)
} }
return fmt.Errorf("%w: %w", ErrWriteFailed, err) return NewConnectionError(fmt.Errorf("%w: %w", ErrWriteFailed, err))
} }
return nil return nil
+1 -1
View File
@@ -73,7 +73,7 @@ func TestDisconnectedConnectionClose(t *testing.T) {
err = conn.Send([]byte("test")) err = conn.Send([]byte("test"))
assert.Error(t, err) assert.Error(t, err)
assert.ErrorContains(t, err, "connection closed") assert.ErrorIs(t, err, ErrConnectionClosed)
}) })
} }
+1 -1
View File
@@ -213,7 +213,7 @@ func TestConnectionSend(t *testing.T) {
defer conn.Close() defer conn.Close()
err = conn.Send([]byte("test")) err = conn.Send([]byte("test"))
assert.ErrorContains(t, err, "failed to set write deadline: test error") assert.ErrorIs(t, err, ErrFailedWriteDeadline)
honeybeetest.Eventually(t, func() bool { honeybeetest.Eventually(t, func() bool {
return conn.State() == StateClosed return conn.State() == StateClosed
+67 -10
View File
@@ -3,9 +3,11 @@ package transport
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"git.wisehodl.dev/jay/go-honeybee/honeybeetest" "git.wisehodl.dev/jay/go-honeybee/honeybeetest"
"git.wisehodl.dev/jay/go-honeybee/types" "git.wisehodl.dev/jay/go-honeybee/types"
"github.com/gorilla/websocket"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"io" "io"
"net/http" "net/http"
@@ -241,7 +243,7 @@ func TestConnect(t *testing.T) {
err = conn.Connect(context.Background()) err = conn.Connect(context.Background())
assert.Error(t, err) assert.Error(t, err)
assert.ErrorContains(t, err, "already has socket") assert.ErrorIs(t, err, ErrSocketExists)
assert.Equal(t, StateDisconnected, conn.State()) assert.Equal(t, StateDisconnected, conn.State())
}) })
@@ -253,7 +255,7 @@ func TestConnect(t *testing.T) {
err = conn.Connect(context.Background()) err = conn.Connect(context.Background())
assert.Error(t, err) assert.Error(t, err)
assert.ErrorContains(t, err, "connection is closed") assert.ErrorIs(t, err, ErrConnectionClosed)
assert.Equal(t, StateClosed, conn.State()) assert.Equal(t, StateClosed, conn.State())
}) })
@@ -467,17 +469,72 @@ func TestConnectionIncoming(t *testing.T) {
} }
func TestConnectionErrors(t *testing.T) { func TestConnectionErrors(t *testing.T) {
conn, err := NewConnection("ws://test", nil, nil) t.Run("clean close by peer", func(t *testing.T) {
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
return 0, nil, &websocket.CloseError{
Code: websocket.CloseNormalClosure,
Text: "goodbye",
}
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
assert.NoError(t, err) assert.NoError(t, err)
defer conn.Close()
errors := conn.Errors() honeybeetest.Eventually(t, func() bool {
assert.NotNil(t, errors) select {
case err := <-conn.Errors():
return errors.Is(err, ErrPeerClosedClean)
default:
return false
}
}, "expected clean close error")
})
// send data through the channel to verify they are the same t.Run("unexpected close", func(t *testing.T) {
testErr := fmt.Errorf("test error") mockSocket := honeybeetest.NewMockSocket()
conn.errors <- testErr mockSocket.ReadMessageFunc = func() (int, []byte, error) {
received := <-errors return 0, nil, &websocket.CloseError{
assert.Equal(t, testErr, received) Code: websocket.CloseProtocolError,
Text: "bad protocol",
}
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
assert.NoError(t, err)
defer conn.Close()
honeybeetest.Eventually(t, func() bool {
select {
case err := <-conn.Errors():
return errors.Is(err, ErrPeerClosedUnexpected)
default:
return false
}
}, "expected unexpected close error")
})
t.Run("read error", func(t *testing.T) {
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
return 0, nil, io.EOF
}
conn, err := NewConnectionFromSocket(mockSocket, nil, nil)
assert.NoError(t, err)
defer conn.Close()
honeybeetest.Eventually(t, func() bool {
select {
case err := <-conn.Errors():
return errors.Is(err, ErrReadError)
default:
return false
}
}, "expected read error")
})
} }
// Test helpers // Test helpers
+16 -4
View File
@@ -13,16 +13,28 @@ var (
InvalidRetryInitialDelay = errors.New("initial delay must be positive") InvalidRetryInitialDelay = errors.New("initial delay must be positive")
InvalidRetryMaxDelay = errors.New("max delay must be positive") InvalidRetryMaxDelay = errors.New("max delay must be positive")
InvalidRetryJitterFactor = errors.New("jitter factor must be between 0.0 and 1.0") InvalidRetryJitterFactor = errors.New("jitter factor must be between 0.0 and 1.0")
InvalidDelays = errors.New("initial delay may not exceed maximum delay")
// Socket Errors
ErrNilRetryManager = errors.New("retry manager cannot be nil")
ErrNilDialer = errors.New("dialer cannot be nil")
ErrEmptyURL = errors.New("URL cannot be empty")
// Connection Errors // Connection Errors
ErrConnectionClosed = errors.New("connection closed") ErrConnectionClosed = errors.New("connection closed")
ErrWriteFailed = errors.New("write failed") ErrWriteFailed = errors.New("write failed")
ErrNilSocket = errors.New("socket cannot be nil")
ErrSocketExists = errors.New("socket already exists")
ErrFailedWriteDeadline = errors.New("failed to set write deadline")
ErrPeerClosedClean = errors.New("peer closed connection cleanly")
ErrPeerClosedUnexpected = errors.New("peer closed connection unexpectedly")
ErrReadError = errors.New("read error")
) )
func NewConfigError(text string) error { func NewConfigError(err error) error {
return fmt.Errorf("configuration error: %s", text) return fmt.Errorf("configuration error: %w", err)
} }
func NewConnectionError(text string) error { func NewConnectionError(err error) error {
return fmt.Errorf("connection error: %s", text) return fmt.Errorf("connection error: %w", err)
} }
+3 -3
View File
@@ -54,13 +54,13 @@ func AcquireSocket(
} }
if retryMgr == nil { if retryMgr == nil {
return nil, nil, NewConnectionError("retry manager cannot be nil") return nil, nil, NewConnectionError(ErrNilRetryManager)
} }
if dialer == nil { if dialer == nil {
return nil, nil, NewConnectionError("dialer cannot be nil") return nil, nil, NewConnectionError(ErrNilDialer)
} }
if url == "" { if url == "" {
return nil, nil, NewConnectionError("URL cannot be empty") return nil, nil, NewConnectionError(ErrEmptyURL)
} }
for { for {