refactor: phase 2 integrated new dialer into worker

This commit is contained in:
Jay
2026-06-06 17:32:03 -04:00
parent 7bc576ed98
commit a46e9f7ad8
13 changed files with 366 additions and 1026 deletions
+23 -15
View File
@@ -15,6 +15,21 @@ import (
"github.com/gorilla/websocket"
)
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
var (
ErrConnectionClosed = errors.New("connection closed")
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")
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
@@ -40,22 +55,19 @@ type ConnectionConfig struct {
}
func NewConnectionConfig(opts ...ConnectionOption) (*ConnectionConfig, error) {
conf := &ConnectionConfig{
cfg := &ConnectionConfig{
WriteTimeout: 30 * time.Second,
PingInterval: 20 * time.Second,
IncomingBufferSize: 100,
ErrorsBufferSize: 10,
}
for _, o := range opts {
o(conf)
o(cfg)
}
err := ValidateConnectionConfig(*conf)
if err != nil {
if err := ValidateConnectionConfig(*cfg); err != nil {
return nil, err
}
return conf, nil
return cfg, nil
}
type ConnectionOption func(*ConnectionConfig)
@@ -90,21 +102,17 @@ func ValidateConnectionConfig(c ConnectionConfig) error {
if c.WriteTimeout < 0 {
return fmt.Errorf("invalid write timeout: %v", c.WriteTimeout)
}
if c.PingInterval < 0 {
return fmt.Errorf("invalid ping interval: %v", c.PingInterval)
}
if c.IncomingBufferSize < 1 {
return fmt.Errorf("invalid incoming buffer size: %d",
c.IncomingBufferSize)
}
if c.ErrorsBufferSize < 1 {
return fmt.Errorf("invalid errors buffer size: %d",
c.ErrorsBufferSize)
}
return nil
}
@@ -142,7 +150,7 @@ func NewConnection(
ctx context.Context, socket types.Socket, config *ConnectionConfig, handler slog.Handler,
) (*Connection, error) {
if socket == nil {
return nil, NewConnectionError(ErrNilSocket)
return nil, ErrNilSocket
}
if config == nil {
@@ -197,7 +205,7 @@ func (c *Connection) Send(data []byte) error {
defer c.writeMu.Unlock()
if c.closed {
return NewConnectionError(ErrConnectionClosed)
return ErrConnectionClosed
}
// setup
@@ -206,7 +214,7 @@ func (c *Connection) Send(data []byte) error {
if c.logger != nil {
c.logger.Error("write deadline error", "error", err)
}
return NewConnectionError(fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err))
return fmt.Errorf("%w: %w", ErrFailedWriteDeadline, err)
}
}
@@ -217,7 +225,7 @@ func (c *Connection) Send(data []byte) error {
if c.logger != nil {
c.logger.Error("write error", "error", err)
}
return NewConnectionError(fmt.Errorf("%w: %w", ErrWriteFailed, err))
return fmt.Errorf("%w: %w", ErrWriteFailed, err)
}
c.outgoingCount.Add(1)
+4 -4
View File
@@ -112,7 +112,7 @@ func TestNewConnection(t *testing.T) {
// Accessors
// ----------------------------------------------------------------------------
func TestConnectionIncoming(t *testing.T) {
func TestConnection_Incoming(t *testing.T) {
conn, _, _, _ := setupTestConnection(t)
incoming := conn.Incoming()
@@ -125,7 +125,7 @@ func TestConnectionIncoming(t *testing.T) {
assert.Equal(t, testData, received)
}
func TestConnectionErrors(t *testing.T) {
func TestConnection_Errors(t *testing.T) {
t.Run("clean close by peer", func(t *testing.T) {
mockSocket := honeybeetest.NewMockSocket()
mockSocket.ReadMessageFunc = func() (int, []byte, error) {
@@ -194,7 +194,7 @@ func TestConnectionErrors(t *testing.T) {
})
}
func TestConnectionHeartbeat(t *testing.T) {
func TestConnection_Heartbeat(t *testing.T) {
t.Run("pinger sends ping frames", func(t *testing.T) {
pingCount := atomic.Int32{}
socket, _, _ := honeybeetest.SetupTestSocket(t)
@@ -248,7 +248,7 @@ func TestConnectionHeartbeat(t *testing.T) {
// Send
// ----------------------------------------------------------------------------
func TestConnectionSend(t *testing.T) {
func TestConnection_Send(t *testing.T) {
t.Run("writes message to socket", func(t *testing.T) {
conn, _, _, outgoingData := setupTestConnection(t)
defer conn.Close()
-41
View File
@@ -1,41 +0,0 @@
package transport
import "errors"
import "fmt"
var (
// URL Errors
InvalidProtocol = errors.New("URL must use ws:// or wss:// scheme")
// Configuration Errors
InvalidWriteTimeout = errors.New("write timeout cannot be negative")
InvalidPingInterval = errors.New("ping interval cannot be negative")
InvalidBufferSize = errors.New("buffer size must be greater than zero")
InvalidRetryMaxRetries = errors.New("max retry count cannot be negative")
InvalidRetryInitialDelay = errors.New("initial 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")
InvalidDelays = errors.New("initial delay may not exceed maximum delay")
// Socket Errors
ErrNilDialFunc = errors.New("dial func cannot be nil")
// Connection Errors
ErrConnectionClosed = errors.New("connection closed")
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(err error) error {
return fmt.Errorf("configuration error: %w", err)
}
func NewConnectionError(err error) error {
return fmt.Errorf("connection error: %w", err)
}
+14 -9
View File
@@ -2,6 +2,7 @@ package transport
import (
"context"
"errors"
"log/slog"
"net"
"net/http"
@@ -12,6 +13,14 @@ import (
"github.com/gorilla/websocket"
)
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
var (
ErrNilDialFunc = errors.New("dial func cannot be nil")
)
// ----------------------------------------------------------------------------
// Types
// ----------------------------------------------------------------------------
@@ -51,11 +60,11 @@ func (d *GorillaDialer) DialContext(
// Retry Dialer
// ----------------------------------------------------------------------------
func AcquireSocket(
func DialWithRetry(
ctx context.Context,
mgr *RetryManager,
dialFn func(ctx context.Context) (types.Socket, error),
errCh chan<- error,
dialFn func(context.Context) (types.Socket, error),
onError func(error),
handler slog.Handler,
) (types.Socket, error) {
if component.FromContext(ctx) == nil {
@@ -88,12 +97,8 @@ func AcquireSocket(
return socket, nil
}
if errCh != nil {
select {
case <-ctx.Done():
return nil, ctx.Err()
case errCh <- err:
}
if onError != nil {
onError(err)
}
if mgr == nil {
+21 -25
View File
@@ -28,7 +28,7 @@ func TestNewGorillaDialer(t *testing.T) {
assert.NotNil(t, dialer.Dialer.NetDialContext)
}
func TestAcquireSocket(t *testing.T) {
func TestDialWithRetry(t *testing.T) {
cases := []struct {
name string
mockRuns []error
@@ -85,7 +85,7 @@ func TestAcquireSocket(t *testing.T) {
JitterFactor: 0.0,
})
socket, err := AcquireSocket(
socket, err := DialWithRetry(
context.Background(), retryMgr, dial, nil, nil)
assert.Equal(t, tc.wantRetryCount, retryMgr.RetryCount())
@@ -103,17 +103,17 @@ func TestAcquireSocket(t *testing.T) {
dial := func(ctx context.Context) (types.Socket, error) {
return nil, errors.New("dial failed")
}
_, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
_, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
assert.Error(t, err)
})
}
func TestAcquireSocketNoRetry(t *testing.T) {
func TestDialWithRetry_NoRetry(t *testing.T) {
t.Run("success", func(t *testing.T) {
dial := func(ctx context.Context) (types.Socket, error) {
return honeybeetest.NewMockSocket(), nil
}
socket, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
socket, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
assert.NotNil(t, socket)
assert.NoError(t, err)
})
@@ -122,12 +122,12 @@ func TestAcquireSocketNoRetry(t *testing.T) {
dial := func(ctx context.Context) (types.Socket, error) {
return nil, errors.New("dial failed")
}
_, err := AcquireSocket(context.Background(), nil, dial, nil, nil)
_, err := DialWithRetry(context.Background(), nil, dial, nil, nil)
assert.Error(t, err)
})
}
func TestAcquireSocketGuards(t *testing.T) {
func TestDialWithRetry_Guards(t *testing.T) {
validRetryConfig, _ := NewRetryConfig()
validRetryMgr := NewRetryManager(validRetryConfig)
@@ -146,7 +146,7 @@ func TestAcquireSocketGuards(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
socket, err := AcquireSocket(
socket, err := DialWithRetry(
context.Background(), tc.retryMgr, tc.dialFn, nil, nil)
assert.Error(t, err)
@@ -156,7 +156,7 @@ func TestAcquireSocketGuards(t *testing.T) {
}
}
func TestAcquireSocketContextCancellation(t *testing.T) {
func TestDialWithRetry_ContextCancellation(t *testing.T) {
t.Run("context cancelled during sleep returns before next attempt", func(t *testing.T) {
dialCount := atomic.Int32{}
dial := func(ctx context.Context) (types.Socket, error) {
@@ -181,7 +181,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
done := make(chan error, 1)
go func() {
_, err := AcquireSocket(ctx, retryMgr, dial, nil, nil)
_, err := DialWithRetry(ctx, retryMgr, dial, nil, nil)
done <- err
}()
@@ -198,7 +198,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
// dial count is 2 because the first retry is always immediate
assert.Equal(t, int32(2), dialCount.Load())
case <-time.After(honeybeetest.TestTimeout):
t.Fatal("AcquireSocket did not return after context cancellation")
t.Fatal("DialWithRetry did not return after context cancellation")
}
})
@@ -217,7 +217,7 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
retryMgr := NewRetryManager(retryCfg)
done := make(chan error, 1)
go func() {
_, err := AcquireSocket(ctx, retryMgr, dial, nil, nil)
_, err := DialWithRetry(ctx, retryMgr, dial, nil, nil)
done <- err
}()
@@ -229,13 +229,13 @@ func TestAcquireSocketContextCancellation(t *testing.T) {
case err := <-done:
assert.ErrorIs(t, err, context.Canceled)
case <-time.After(honeybeetest.TestTimeout):
t.Fatal("AcquireSocket did not return after context cancellation")
t.Fatal("DialWithRetry did not return after context cancellation")
}
})
}
func TestAcquireSocketDialErrors(t *testing.T) {
func TestDialWithRetry_DialErrors(t *testing.T) {
t.Run("receive one error per failed dial", func(t *testing.T) {
dialErr1 := errors.New("attempt 1 failed")
dialErr2 := errors.New("attempt 2 failed")
@@ -252,7 +252,8 @@ func TestAcquireSocketDialErrors(t *testing.T) {
}
}
errCh := make(chan error, 2)
var gotErrors []error
onError := func(err error) { gotErrors = append(gotErrors, err) }
retryCfg, _ := NewRetryConfig(
WithMaxRetries(3),
@@ -263,17 +264,11 @@ func TestAcquireSocketDialErrors(t *testing.T) {
retryMgr := NewRetryManager(retryCfg)
go func() {
_, err := AcquireSocket(context.Background(), retryMgr, dial, errCh, nil)
_, err := DialWithRetry(context.Background(), retryMgr, dial, onError, nil)
assert.NoError(t, err)
}()
var gotErrors []error
honeybeetest.Eventually(t, func() bool {
select {
case err := <-errCh:
gotErrors = append(gotErrors, err)
default:
}
return len(gotErrors) == 2
}, "expected errors")
@@ -286,7 +281,8 @@ func TestAcquireSocketDialErrors(t *testing.T) {
return honeybeetest.NewMockSocket(), nil
}
errCh := make(chan error, 1)
called := false
onError := func(err error) { called = true }
retryCfg, _ := NewRetryConfig(
WithMaxRetries(3),
@@ -296,9 +292,9 @@ func TestAcquireSocketDialErrors(t *testing.T) {
)
retryMgr := NewRetryManager(retryCfg)
_, err := AcquireSocket(context.Background(), retryMgr, dial, errCh, nil)
_, err := DialWithRetry(context.Background(), retryMgr, dial, onError, nil)
assert.NoError(t, err)
assert.Len(t, errCh, 0)
assert.False(t, called)
})
}
+3
View File
@@ -1,10 +1,13 @@
package transport
import (
"errors"
"net/url"
"strings"
)
var InvalidProtocol = errors.New("URL must use ws:// or wss:// scheme")
func ParseURL(urlStr string) (*url.URL, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {