updated documentation.

This commit is contained in:
Jay
2026-04-24 15:12:56 -04:00
parent 6a3ba05fd5
commit 6332e4438e
3 changed files with 407 additions and 150 deletions
+193
View File
@@ -0,0 +1,193 @@
# Configuration
All configuration is done through option functions applied at construction time. Invalid values return errors at application time; configs produced through `NewXConfig` constructors are validated at construction and cannot be saved in an invalid state.
There are three config scopes. `ConnectionConfig` governs a single WebSocket connection: its write behavior, ping interval, buffers, and retry policy. `WorkerConfig` governs a single worker's behavior, with separate types for inbound and outbound. `PoolConfig` bundles a connection config, a worker config, and an optional worker factory — it is a thin container.
Logging can be controlled independently at the pool, worker, and connection levels. Each scope has a `LoggingEnabled` flag and a `LogLevel` override. When `LoggingEnabled` is false, no logger is constructed for that scope regardless of what handler is passed to the pool. When `LogLevel` is set, it overrides the handler's own level filter for that scope only, using a wrapping handler that enforces the minimum level before delegating. When `LogLevel` is nil, the handler's own level filtering applies unchanged.
## Connection Options
These are passed to `NewConnectionConfig` or supplied via `WithInboundConnectionConfig` / `WithOutboundConnectionConfig`.
### Write Behavior
**`WithWriteTimeout(duration)`**
Sets a per-message write deadline. Applied before every call to `WriteMessage`. When set to zero, no deadline is applied. Must not be negative.
**`WithCloseHandler(func(code int, text string) error)`**
Installs a close handler on the underlying socket. Called when the remote peer sends a close frame.
### Ping
**`WithPingInterval(duration)`**
Sets the interval at which the connection sends WebSocket ping frames. A ±10% jitter is applied to each interval to avoid synchronized pings across many connections. When set to zero, pings are disabled entirely, and only data messages and outbound sends generate heartbeat signals. Must not be negative.
### Buffers
**`WithIncomingBufferSize(int)`**
Sets the capacity of the channel that buffers inbound messages between the reader goroutine and the consumer. Must be at least 1.
**`WithErrorsBufferSize(int)`**
Sets the capacity of the channel that carries connection-level errors to the consumer. Must be at least 1.
### Retry
The retry policy governs the `Connect()` call only. It does not affect outbound worker reconnection, which is controlled by `ReconnectDelay` on the worker config.
**`WithoutRetry()`**
Disables retry entirely. `Connect()` returns on the first dial failure.
**`WithRetryMaxRetries(int)`**
Caps the number of retry attempts. Zero means retry indefinitely. Must not be negative.
**`WithRetryInitialDelay(duration)`**
Sets the delay before the second dial attempt. The first retry is always immediate. Must be positive.
**`WithRetryMaxDelay(duration)`**
Caps the exponential backoff delay. Must be positive and at least as large as `InitialDelay`.
**`WithRetryJitterFactor(float64)`**
Adds randomization to each backoff delay. A value of 0.2 means the delay varies within ±10% of the base. Range: 0.0 to 1.0.
### Logging
**`WithConnectionLoggingEnabled(bool)`**
Enables or disables logging for the connection. When false, no logger is constructed regardless of the handler passed to the pool.
**`WithConnectionLogLevel(slog.Level)`**
Overrides the minimum log level for connection-scoped records. Does not affect pool or worker logging.
## Inbound Pool Options
### Pool
These are passed to `NewInboundPoolConfig`.
**`WithInboundInboxBufferSize(int)`**
Sets the capacity of the pool's shared inbox channel. Must be at least 1.
**`WithInboundEventsBufferSize(int)`**
Sets the capacity of the pool's events channel. Must be at least 1.
**`WithInboundErrorsBufferSize(int)`**
Sets the capacity of the pool's errors channel. Must be at least 1.
**`WithInboundPoolLoggingEnabled(bool)`**
Enables or disables pool-level logging.
**`WithInboundPoolLogLevel(slog.Level)`**
Overrides the minimum log level for pool-scoped records only.
### Worker
These are passed to `NewInboundWorkerConfig` or embedded in the pool config.
**`WithInboundInactivityTimeout(duration)`**
Enables the inactivity watchdog. When no data messages or pong replies arrive within this duration, the peer is evicted and `InboundEventEvictedPolicy` is emitted. When set to zero, the watchdog is disabled and connections persist until removed or remotely terminated. Must not be negative.
**`WithInboundMaxQueueSize(int)`**
Bounds the worker's internal message queue. When the queue is full, the oldest undelivered message is dropped. When set to zero, the queue is unbounded. Must not be negative.
**`WithInboundWorkerLoggingEnabled(bool)`**
Enables or disables worker-level logging.
**`WithInboundWorkerLogLevel(slog.Level)`**
Overrides the minimum log level for worker-scoped records only.
### Wiring
**`WithInboundConnectionConfig(*ConnectionConfig)`**
Supplies a connection config that is applied to every socket added to the pool.
**`WithInboundWorkerConfig(*WorkerConfig)`**
Supplies a worker config that is applied to every worker the pool creates.
**`WithInboundWorkerFactory(WorkerFactory)`**
Replaces the default worker constructor. See [EXTEND.md](EXTEND.md) for the factory contract.
## Outbound Pool Options
### Pool
These are passed to `NewOutboundPoolConfig`.
**`WithOutboundInboxBufferSize(int)`**
Sets the capacity of the pool's shared inbox channel. Must be at least 1.
**`WithOutboundEventsBufferSize(int)`**
Sets the capacity of the pool's events channel. Must be at least 1.
**`WithOutboundErrorsBufferSize(int)`**
Sets the capacity of the pool's errors channel. Must be at least 1.
**`WithOutboundPoolLoggingEnabled(bool)`**
Enables or disables pool-level logging.
**`WithOutboundPoolLogLevel(slog.Level)`**
Overrides the minimum log level for pool-scoped records only.
### Worker
These are passed to `NewOutboundWorkerConfig` or embedded in the pool config.
**`WithOutboundKeepaliveTimeout(duration)`**
Enables the keepalive mechanism. When no heartbeat (inbound data, outbound send, or pong reply) is observed within this duration, the current connection is closed and a new one is dialed. When set to zero, keepalive is disabled. Must not be negative.
**`WithReconnectDelay(duration)`**
Sets the delay between a disconnect and the next dial attempt. Applies after every session end, including those triggered by keepalive. The default of 2 seconds prevents tight reconnect loops against unavailable peers. Set to zero in tests or when immediate reconnection is required. Must not be negative.
**`WithOutboundMaxQueueSize(int)`**
Bounds the worker's internal message queue. When the queue is full, the oldest undelivered message is dropped. When set to zero, the queue is unbounded. Must not be negative.
**`WithOutboundWorkerLoggingEnabled(bool)`**
Enables or disables worker-level logging.
**`WithOutboundWorkerLogLevel(slog.Level)`**
Overrides the minimum log level for worker-scoped records only.
### Wiring
**`WithOutboundConnectionConfig(*ConnectionConfig)`**
Supplies a connection config used when dialing each peer.
**`WithOutboundWorkerConfig(*WorkerConfig)`**
Supplies a worker config applied to every worker the pool creates.
**`WithOutboundWorkerFactory(WorkerFactory)`**
Replaces the default worker constructor. See [EXTEND.md](EXTEND.md) for the factory contract.
## Defaults
| Scope | Setting | Default | Disabled by | Notes |
|---|---|---|---|---|
| Connection | `WriteTimeout` | 30s | `0` | Per-message write deadline |
| Connection | `PingInterval` | 20s | `0` | ±10% jitter applied per interval |
| Connection | `IncomingBufferSize` | 100 | — | Must be positive |
| Connection | `ErrorsBufferSize` | 10 | — | Must be positive |
| Connection | `LoggingEnabled` | true | `false` | |
| Connection | `LogLevel` | nil | — | nil defers to handler's own filter |
| Retry | enabled | yes | `WithoutRetry()` | Governs `Connect()` only |
| Retry | `MaxRetries` | 0 | — | 0 means infinite |
| Retry | `InitialDelay` | 1s | — | Must be positive |
| Retry | `MaxDelay` | 60s | — | Must be ≥ InitialDelay |
| Retry | `JitterFactor` | 0.2 | `0.0` | Range [0.0, 1.0] |
| Inbound pool | `InboxBufferSize` | 256 | — | Must be positive |
| Inbound pool | `EventsBufferSize` | 10 | — | Must be positive |
| Inbound pool | `ErrorsBufferSize` | 10 | — | Must be positive |
| Inbound pool | `LoggingEnabled` | true | `false` | |
| Inbound pool | `LogLevel` | nil | — | |
| Inbound worker | `InactivityTimeout` | 0 | `0` | 0 disables watchdog |
| Inbound worker | `MaxQueueSize` | 0 | `0` | 0 means unbounded |
| Inbound worker | `LoggingEnabled` | true | `false` | |
| Inbound worker | `LogLevel` | nil | — | |
| Outbound pool | `InboxBufferSize` | 256 | — | Must be positive |
| Outbound pool | `EventsBufferSize` | 10 | — | Must be positive |
| Outbound pool | `ErrorsBufferSize` | 10 | — | Must be positive |
| Outbound pool | `LoggingEnabled` | true | `false` | |
| Outbound pool | `LogLevel` | nil | — | |
| Outbound worker | `KeepaliveTimeout` | 60s | `0` | 0 disables keepalive |
| Outbound worker | `ReconnectDelay` | 2s | `0` | 0 means reconnect immediately |
| Outbound worker | `MaxQueueSize` | 0 | `0` | 0 means unbounded |
| Outbound worker | `LoggingEnabled` | true | `false` | |
| Outbound worker | `LogLevel` | nil | — | |
+143
View File
@@ -0,0 +1,143 @@
# Extending Pools
The pool owns peer registration, event plumbing, and lifecycle management. The worker owns everything that happens on the wire between registration and the terminal event. Everything between `pool.Add` or `pool.Connect` and the final disconnect event is the worker's responsibility, and it is fully replaceable.
## The Worker Interface
Both pools accept any type that satisfies:
```go
type Worker interface {
Start(pool PoolPlugin)
Stop()
Send(data []byte) error
Stats() WorkerStats
}
````
The behavioral contract for each method:
**`Start(pool PoolPlugin)`** Called by the pool in a goroutine it owns. Must block until the worker is finished. The pool monitors this goroutine via a `sync.WaitGroup`; `Start` returning is the signal that the worker is done. All I/O, goroutine management, and event emission happen inside `Start`.
**`Stop()`** Must cause `Start` to return in bounded time. Typically cancels a context. May be called from any goroutine, including concurrently with `Start`.
**`Send(data []byte) error`** Writes data to the remote peer and returns an error if it cannot. Must be safe for concurrent callers. The pool calls `Send` from whatever goroutine the consumer calls `pool.Send` from.
**`Stats() WorkerStats`** Returns a snapshot of the worker's internal counters and channel depths. Must be safe for concurrent callers and must not block. The pool calls this from `pool.Stats()` and `pool.PeerStats()` while holding a read lock.
## The PoolPlugin
The pool constructs a `PoolPlugin` and passes it to `Start`. It gives the worker access to pool-level channels and the logging handler.
```go
type PoolPlugin struct {
Inbox chan<- InboxMessage
Events chan<- PoolEvent
Errors chan<- error
InboxCounter *atomic.Uint64
OnExit OnExitFunction // inbound only
Handler slog.Handler
}
```
**`Inbox`** The shared channel that delivers received messages to the pool's consumer. All peers in the pool deliver to the same inbox channel. Workers must include their peer ID in each `InboxMessage`.
**`Events`** The shared channel for lifecycle events. Outbound workers emit `EventConnected` and `EventDisconnected` directly. Inbound workers do not touch this channel directly; instead they call `OnExit`, and the pool translates the exit kind into the appropriate event.
**`Errors`** The shared channel for non-fatal errors, such as dial failures on an outbound worker. Errors sent here do not stop the pool.
**`InboxCounter`** An atomic counter owned by the pool. Workers must increment this once for each message forwarded to `Inbox`. The pool reads it in `Stats()`.
**`OnExit` (inbound only)** Must be called exactly once when an inbound worker exits on its own initiative — that is, when the peer closed the connection or the watchdog fired, not when `Stop` was called. The pool wraps the underlying function in a `sync.Once` as a safety net, but a well-behaved worker calls it once. Calling `OnExit` removes the peer from the pool's registry and emits the appropriate event.
**`Handler`** The `slog.Handler` passed to the pool constructor. The pool constructs and injects scoped loggers before calling the factory, so most workers will use the logger they receive from the factory rather than constructing one from `Handler` directly. `Handler` is available for workers that manage sub-components that need their own loggers.
## Extending the Inbound Pool
### Factory Signature
```go
type WorkerFactory func(
ctx context.Context,
id string,
conn *transport.Connection,
config *WorkerConfig,
logger *slog.Logger,
) (Worker, error)
```
The pool calls the factory under its write lock when `Add` or `Replace` is called. The factory must return without blocking. The pool constructs `logger` from the worker logging config before calling the factory; pass it to your worker or ignore it.
The factory is set via `WithInboundWorkerFactory` on the pool config.
### Building Blocks
The default inbound worker is assembled from these exported functions. Each can be reused, replaced, or wrapped independently.
**`RunReader(ctx, onExit, conn, messages, heartbeat, logger)`** Reads from `conn.Incoming()` until the channel closes or `ctx` is cancelled. Forwards each message to `messages` and sends a signal on `heartbeat` for each message received. When the channel closes, classifies the exit — clean disconnect, unexpected close, or read error — and calls `onExit` with the appropriate `WorkerExitKind`. Does not call `onExit` on context cancellation.
**`RunHeartbeatForwarder(ctx, conn, heartbeat, logger)`** Reads from `conn.Heartbeat()` and forwards each signal to `heartbeat`. This propagates pong replies from the connection layer into the worker's heartbeat channel, so pongs reset the inactivity watchdog alongside data messages.
**`RunQueue(id, ctx, in, out, maxQueueSize, droppedCount)`** Buffers messages between the reader and the forwarder. When `maxQueueSize` is positive and the queue is full, the oldest message is dropped and `droppedCount` is incremented. When `maxQueueSize` is zero, the queue grows without bound.
**`RunForwarder(id, ctx, messages, inbox, workerProcessedCount, poolInboxCount)`** Reads from `messages` and writes to `inbox`. Increments both counters on each successful delivery.
**`RunWatchdog(ctx, onInactive, heartbeat, timeout, logger)`** Monitors `heartbeat`. Resets a timer on each signal. When the timer fires, calls `onInactive(ExitPolicy)`. When `timeout` is zero, the watchdog is disabled: it drains `heartbeat` without acting and exits when `ctx` is cancelled.
### Replacement Patterns
**Swap one block.** Embed `*inbound.DefaultWorker`, override `Start`, reuse the goroutines you want unchanged, and substitute your own implementation for the one you are replacing. For example, to tag every forwarded message with a sequence number, reuse `RunReader`, `RunQueue`, and `RunWatchdog` verbatim, and write a custom forwarder.
**Wrap the plugin.** Intercept `Inbox` by substituting a channel you own, process messages, then forward to the original. The worker signature is unchanged; you compose behavior by controlling what the building blocks see.
**Implement from scratch.** Satisfy the `Worker` interface directly. The only obligations are the behavioral contracts on `Start`, `Stop`, `Send`, `Stats`, and `OnExit` described above.
## Extending the Outbound Pool
### Factory Signature
```go
type WorkerFactory func(
ctx context.Context,
id string,
logger *slog.Logger,
) (Worker, error)
```
The pool calls the factory under its write lock when `Connect` is called. The factory must return without blocking. Note that the outbound factory does not receive a `*transport.Connection`; the worker is responsible for dialing and managing its own connections. The pool constructs `logger` from the worker logging config before calling the factory.
The factory is set via `WithOutboundWorkerFactory` on the pool config.
### Building Blocks
**`RunDialer(id, ctx, pool, dial, newConn, logger)`** Listens on `dial` for connection requests. On each signal, calls `connect` to dial a new `*transport.Connection`. While a dial is in progress, drains additional `dial` signals so that at most one dial runs at a time. On failure, sends the error to `pool.Errors` and waits for the next `dial` signal. On success, sends the connection on `newConn`. Exits when `ctx` is cancelled.
**`RunKeepalive(ctx, heartbeat, keepalive, timeout, logger)`** Monitors `heartbeat`. Resets a timer on each signal. When the timer fires, sends a signal on `keepalive` to notify the session that the connection should be replaced. When `timeout` is zero, keepalive is disabled: it drains `heartbeat` without acting and exits when `ctx` is cancelled.
**`RunReader(ctx, onStop, conn, messages, heartbeat, logger)`** Reads from `conn.Incoming()` until the channel closes or `ctx` is cancelled. Forwards each message to `messages` and sends a signal on `heartbeat`. On exit, calls `conn.Close()` and then `onStop`.
**`RunHeartbeatForwarder(ctx, conn, heartbeat, logger)`** Reads from `conn.Heartbeat()` and forwards each signal to `heartbeat`. Propagates pong replies into the worker's heartbeat channel so pongs reset the keepalive timer alongside data messages and sends.
**`RunStopMonitor(ctx, onStop, conn, keepalive, logger)`** Waits for either `ctx.Done` or a signal on `keepalive`. On either, calls `conn.Close()` and then `onStop`. This is how a keepalive expiry propagates into a session tear-down.
**`RunForwarder(id, ctx, messages, inbox, workerProcessedCount, poolInboxCount)`** Reads from `messages` and writes to `inbox`. Increments both counters on each successful delivery. Identical in behavior to the inbound variant.
**`Session`** The coordination struct that ties the above blocks together for one connection lifecycle. `Session.Start` runs a loop: request a dial, wait for a connection, run `RunReader`, `RunHeartbeatForwarder`, and `RunStopMonitor` concurrently, wait for them to finish, emit `EventDisconnected`, sleep for `ReconnectDelay`, then repeat. `Session` is exported so it can be embedded or used directly in a custom worker.
### Replacement Patterns
**Swap one block.** The most common case is replacing `RunReader` to intercept or annotate inbound messages, or replacing `RunKeepalive` with a different activity metric. Reuse `Session` for the connection lifecycle and substitute the one goroutine you need to change.
**Replace the session loop.** Construct your own loop using `RunDialer`, `RunKeepalive`, and the session-level blocks. This gives you control over reconnection logic, back-off behavior, or multi-connection strategies while keeping the lower-level I/O blocks intact.
**Implement from scratch.** Satisfy the `Worker` interface directly. You are responsible for dialing, managing connection state, forwarding messages to `pool.Inbox`, emitting `EventConnected` and `EventDisconnected` to `pool.Events`, and incrementing `pool.InboxCounter`.
## Factory Constraints
Factories for both pool types are called while the pool holds its write lock. Two constraints follow from this directly.
**Factories must not block.** Any operation that could wait — dialing a connection, acquiring another lock, reading from a channel — will deadlock or stall the pool. All blocking work belongs inside `Start`, not inside the factory.
**Factories must not call pool methods.** `pool.Add`, `pool.Send`, `pool.Remove`, and similar methods all acquire the same lock the factory is called under. Calling them from the factory will deadlock.
The factory's only job is to construct and return a worker. If construction itself can fail — for example, because a config value is invalid — return the error and the pool will propagate it to the caller of `Add` or `Connect`.
+68 -147
View File
@@ -8,7 +8,7 @@ WebSocket connection and pool primitives in Go. Built for Nostr.
honeybee.go top-level re-exports and constructors
transport/ single-connection primitives
connection.go *Connection, state machine, reader goroutine
connection.go *Connection, state machine, reader goroutine, pinger
config.go ConnectionConfig, RetryConfig, options
retry.go exponential backoff with jitter
socket.go Dialer interface, AcquireSocket
@@ -24,17 +24,20 @@ outbound/ pool for self-initiated connections
worker.go Worker interface, DefaultWorker, Session, Run* functions
config.go WorkerConfig, PoolConfig, options
logging/ structured log construction
logging.go logger constructors, ForcedLevelHandler
types/ shared interfaces (Dialer, Socket)
honeybeetest/ test helpers and mocks for consumers
```
````
## What This Library Does
Honeybee is a reliable and simple library for managing websocket connections and pools.
- Handles websocket connections and pools cleanly and safely.
- When connecting, robustly retries failed attempts until a connection is achieved.
- Provides two pools: one to manage outbound peers and another to manage inbound peers.
- Exposes statistics at the connection, worker, and pool levels.
- Exposes a means to replace the internal pool worker to inject custom extensions.
## What This Library Does Not Do
@@ -50,13 +53,13 @@ Honeybee does not provide:
- compression strategies, prepared message caching, or encoding optimization.
- authentication, authorization, or session management above the transport.
These are specialized features that deserve robust implementations, but not within Honeybee itself.
These are specialized features that deserve robust implementations elsewhere as on-demand extensions rather than core features.
## Installation
```bash
go get git.wisehodl.dev/jay/go-honeybee
````
```
If the primary repository is unavailable, use the `replace` directive in your go.mod:
@@ -89,11 +92,10 @@ go func() {
}
}()
// send a message
conn.Send([]byte("hello"))
```
The connection goes through four states: `StateDisconnected`, `StateConnecting`, `StateConnected`, `StateClosed`. Transitions are atomic and observable via `conn.State()`. Once closed, the connection should not be reused. Instead, construct a new one with the same `url` and reconnect.
The connection goes through four states: `StateDisconnected`, `StateConnecting`, `StateConnected`, `StateClosed`. Transitions are atomic and observable via `conn.State()`. Once closed, the connection should not be reused. Instead, construct a new one with the same URL and reconnect.
`Send` is safe for concurrent callers. `Close` is idempotent and safe to call from any goroutine.
@@ -101,18 +103,20 @@ When the reader exits, exactly one classified error reaches `Errors()` before th
- `ErrPeerClosedClean` for normal closure
- `ErrPeerClosedUnexpected` for abnormal close codes
- `ErrReadError` for anything else.
- `ErrReadError` for anything else
Consumers use this to decide whether the disconnect was expected. No other errors are sent by the connection.
Pass an `*slog.Logger` as the third argument to get structured logs at INFO, WARN, and ERROR levels. Pass nil to disable logging entirely.
Pass an `*slog.Logger` as the third argument to get structured logs. Pass nil to disable logging entirely.
### Inbound Pool
The inbound pool manages connections initiated by peers. The consumer accepts a WebSocket somewhere else and hands the resulting socket to the pool along with an ID.
Each pool requires a non-empty string ID. This ID is attached to all structured log records emitted by the pool, its workers, and their connections.
```go
pool, err := honeybee.NewInboundPool(ctx, nil, logger)
pool, err := honeybee.NewInboundPool(ctx, "my-pool", nil, handler)
if err != nil { /* handle error */ }
defer pool.Close()
@@ -133,28 +137,30 @@ go func() {
for ev := range pool.Events() {
switch ev.Kind {
case honeybee.InboundEventDisconnected: // clean close
case honeybee.InboundEventDropped: // unexpected drop or read error
case honeybee.InboundEventEvicted: // inactivity timeout, if enabled
case honeybee.InboundEventDroppedClose: // peer closed with abnormal code
case honeybee.InboundEventDroppedError: // read error
case honeybee.InboundEventEvictedPolicy: // inactivity timeout
}
}
}()
// send a message to a specific peer
pool.Send(peerID, []byte("response"))
```
`Add`, `Replace`, and `Remove` do not emit events. Events are emitted only when a worker exits on its own, either when the peer closed the socket or it was determined to be inactive (inactivity monitoring is disabled by default).
`Add`, `Replace`, and `Remove` do not emit events. Events are emitted only when a worker exits on its own, either when the peer closed the socket or it was determined to be inactive.
Use `Replace` if you need to replace a socket for a peer and maintain its ID. No events are emitted during this process.
Use `Replace` if you need to swap the socket for a peer while keeping its ID. No events are emitted during this process.
The watchdog is configured via `WithInboundInactivityTimeout`. When set to zero, it is disabled. When set, the watchdog will observe message traffic on the wire and disconnect if no messages are seen for the configured duration. The watchdog is disabled by default, meaning that connections will persist until manually removed or remotely terminated.
The watchdog is configured via `WithInboundInactivityTimeout`. When set to zero, it is disabled. When set, the watchdog observes message traffic and disconnects the peer if no messages arrive within the configured duration. The watchdog is disabled by default, meaning connections persist until manually removed or remotely terminated.
### Outbound Pool
The outbound pool connects to peers by their URLs and keeps them connected. It reconnects automatically when a connection drops and proactively refreshes inactive connections.
Each pool requires a non-empty string ID. This ID is attached to all structured log records emitted by the pool, its workers, and their connections.
```go
pool, err := honeybee.NewOutboundPool(ctx, nil, logger)
pool, err := honeybee.NewOutboundPool(ctx, "my-pool", nil, handler)
if err != nil { /* handle error */ }
defer pool.Close()
@@ -170,7 +176,6 @@ go func() {
go func() {
for ev := range pool.Events() {
// used to determine when a connection is live
switch ev.Kind {
case honeybee.OutboundEventConnected:
case honeybee.OutboundEventDisconnected:
@@ -178,152 +183,65 @@ go func() {
}
}()
// send a message to a specific peer
pool.Send("wss://peer.example.com", []byte("hello"))
```
URLs are normalized by the pool. For example: `wss://peer.example.com`, `wss://peer.example.com/`, and `WSS://Peer.Example.Com:443` all identify the same peer.
URLs are normalized by the pool. `wss://peer.example.com`, `wss://peer.example.com/`, and `WSS://Peer.Example.Com:443` all identify the same peer. `honeybee.NormalizeURL` is also available directly if you need to use the same URLs as keys elsewhere.
Every time a connection is established, `OutboundEventConnected` is emitted. Every time a connection drops for any reason, `OutboundEventDisconnected` is emitted. A peer that reconnects three times produces three Connected/Disconnected pairs.
Keepalive is configured via `WithOutboundKeepaliveTimeout`. The worker records a heartbeat on every inbound message and every successful send. If no heartbeats come in before the keepalive timer runs out, the connection is proactively disconnected and reconnected. When set to zero, the keepalive mechanism is disabled.
Keepalive is configured via `WithOutboundKeepaliveTimeout`. The worker records a heartbeat on every inbound message, every successful send, and every received pong. If no heartbeats arrive before the keepalive timer fires, the connection is proactively disconnected and reconnected. When set to zero, keepalive is disabled.
`Send` returns `ErrConnectionUnavailable` during the gap between a disconnect and the next successful reconnect. Callers should try again after observing an `OutboundEventConnected` event and maintain their own write buffers.
After a disconnect, the worker waits for `ReconnectDelay` before attempting the next connection. The default is 2 seconds. Set to zero in tests or when you need immediate reconnection.
Dial failures surface on `pool.Errors()`. These do not stop the pool though. It will continue retrying according to the connection's retry config and the keepalive mechanism.
`Send` returns `ErrConnectionUnavailable` during the gap between a disconnect and the next successful reconnect. Callers should wait for `OutboundEventConnected` before retrying and maintain their own write buffers if needed.
## Extensibility
Dial failures surface on `pool.Errors()`. These do not stop the pool; it continues retrying according to the connection's retry config.
The pool owns peer registry, event plumbing, and lifecycle. The worker owns what happens on the wire. Everything between `pool.Add` or `pool.Connect` and the `InboundEventDisconnected`/`OutboundEventDisconnected` event is the worker's responsibility, and it is fully replaceable.
## Ping-Pong Heartbeats
### The Worker Interface
Connections send periodic WebSocket ping frames and listen for the corresponding pong replies. A received pong registers as a heartbeat signal within the worker.
Both pools accept any type implementing:
For inbound workers, pong-derived heartbeats reset the inactivity watchdog timer alongside data messages. A peer that sends no data but responds to pings will not be evicted.
For outbound workers, pong-derived heartbeats reset the keepalive timer. A peer that sends no data but responds to pings will not be disconnected and reconnected by the keepalive mechanism.
The ping interval is configured via `WithPingInterval` on the `ConnectionConfig`. The default is 20 seconds. Set to zero to disable pings entirely, in which case only data messages and outbound sends generate heartbeats.
## Statistics
Pools, workers, and connections expose counters and channel depths that can be sampled at any time. All values are snapshots; counters are monotonically increasing and are not reset between reconnects on an outbound worker.
```go
type Worker interface {
Start(pool PoolPlugin)
Stop()
Send(data []byte) error
}
// Pool-level snapshot
stats := pool.Stats()
// stats.PeerCount — number of currently registered peers
// stats.TotalReceived — messages delivered to pool.Inbox() since construction
// stats.TotalSent — messages sent via pool.Send() since construction
// stats.ChanInbox — current depth of the inbox channel
// stats.PeerStats — one entry per connected peer
// Single peer
peerStats, err := pool.PeerStats(peerID)
// peerStats.Worker — queue depths, processed/dropped/sent counts
// peerStats.Connection — channel depths, receive/send/heartbeat counts (inbound)
// Bare connection
connStats := conn.Stats()
// connStats.TotalReceived, connStats.TotalSent, connStats.TotalHeartbeats
```
`PoolPlugin` differs slightly between inbound and outbound, giving the worker access to the pool's inbox channel, events channel, logger, and (for inbound) an `OnExit` callback. The pool calls `Start` in a goroutine it owns and expects `Start` to return when the worker is done.
## Extending Pools
### The Factory Pattern
The pool owns peer registration, event plumbing, and lifecycle. The worker owns what happens on the wire. The default worker can be replaced entirely or composed from the exported `Run*` building blocks that Honeybee provides.
Workers are constructed by factories injected into the pool config:
```go
config, _ := honeybee.NewInboundPoolConfig(
honeybee.WithInboundWorkerFactory(myFactory),
)
```
Factories run under the pool's write lock during `Add` or `Connect`, so they must be non-blocking. Anything requiring I/O belongs inside `Start`, not inside the factory.
### Three Levels of Customization
Most will want level one. A few may want level two. Level three is there when you need it.
**Level 1: Use the default worker.** It handles everything described in the usage sections. No factory needed.
**Level 2: Compose the exported `Run*` functions.** Honeybee exports the building blocks the default workers are built from:
- Inbound: `RunReader`, `RunForwarder`, `RunWatchdog`.
- Outbound: `RunDialer`, `RunKeepalive`, `RunForwarder`, `Session`, `RunReader`, `RunStopMonitor`.
A custom worker can reuse most of these and replace one. For example, an inbound worker that wants to tag every message with a receive sequence number before forwarding can reuse `RunReader` and `RunWatchdog` verbatim and write a custom forwarder that wraps the default behavior:
```go
type SequencedWorker struct {
*inbound.DefaultWorker
seq atomic.Uint64
}
func (w *SequencedWorker) Start(pool inbound.PoolPlugin) {
// wrap pool.Inbox with a channel that tags messages,
// call w.DefaultWorker.Start with the wrapped plugin
}
```
**Level 3: Implement `Worker` from scratch.** The contract is minimal:
1. `Start` runs until the worker is done, then returns. The pool handles its
own waitgroup to monitor each worker.
2. `Stop` causes `Start` to return in bounded time. Typically this cancels a context.
3. `Send` writes data and returns an error if it cannot. It is called from arbitrary goroutines and must be safe for concurrent use.
4. For inbound workers, call `pool.OnExit(kind)` exactly once when the worker exits on its own (not in response to `Stop`). The pool wraps this in `sync.Once` defensively, but a well-behaved worker calls it once.
5. Forward received bytes to `pool.Inbox` as `InboxMessage` values. Emit events by letting the pool do it through `OnExit`; the worker does not touch `pool.Events` directly.
The pool will not retry a failed factory call, will not rescue a worker whose `Start` blocks forever, and will not interpret the errors `Send` returns.
See EXTEND.md for the worker interface contract, the `PoolPlugin` fields, and the available building blocks for both inbound and outbound pools.
## Configuration
Three config types cover three scopes.
All configuration is done through option functions applied at construction time. There are three config scopes: `ConnectionConfig`, `WorkerConfig`, and `PoolConfig`. Logging can be enabled and its minimum level overridden independently at the pool, worker, and connection levels.
`ConnectionConfig` governs a single connection's behavior: write timeout, close handler, and retry policy. `RetryConfig` is embedded inside it and governs the `Connect()` retry loop.
`WorkerConfig` governs a single worker's behavior. Inbound and outbound each have their own, with fields specific to their direction.
`PoolConfig` bundles a connection config, a worker config, and an optional worker factory. It is a thin container.
### Option Functions
Connection and retry:
- `WithCloseHandler(func)` installs a close handler on the socket.
- `WithWriteTimeout(duration)` sets per-message write deadline.
- `WithIncomingBufferSize(int)` sets the connection's incoming message channel buffer.
- `WithErrorsBufferSize(int)` sets the connection's errors channel buffer.
- `WithoutRetry()` disables retry entirely.
- `WithRetryMaxRetries(int)` caps retry attempts; zero means infinite.
- `WithRetryInitialDelay(duration)` sets the first backoff interval.
- `WithRetryMaxDelay(duration)` caps the backoff interval.
- `WithRetryJitterFactor(float64)` adds randomization to backoff, range 0.0 to 1.0.
Inbound worker:
- `WithInboundInactivityTimeout(duration)` enables the watchdog.
- `WithInboundMaxQueueSize(int)` bounds the forwarder's internal queue.
Outbound worker:
- `WithOutboundKeepaliveTimeout(duration)` enables keepalive.
- `WithOutboundMaxQueueSize(int)` bounds the forwarder's internal queue.
Pool wiring (both directions have inbound and outbound variants):
- `With{Inbound,Outbound}InboxBufferSize(int)` sets the pool's inbox channel buffer.
- `With{Inbound,Outbound}EventsBufferSize(int)` sets the pool's events channel buffer.
- `With{Inbound,Outbound}ErrorsBufferSize(int)` sets the pool's errors channel buffer.
- `With{Inbound,Outbound}ConnectionConfig(*ConnectionConfig)`
- `With{Inbound,Outbound}WorkerConfig(*WorkerConfig)`
- `With{Inbound,Outbound}WorkerFactory(WorkerFactory)`
All option functions validate their inputs. Invalid values return errors at application time. Configs constructed via `NewXConfig` are validated at construction and cannot be saved in an invalid state.
### Defaults
| Setting | Default | Disabled Value | Notes |
|----------------------------------|---------|------------------|---------------------------------|
| `WriteTimeout` | 30s | `0` | Per-message write deadline |
| `Retry` enabled | yes | `WithoutRetry()` | Applies to `Connect()` only |
| `Retry.MaxRetries` | `0` | — | `0` means infinite |
| `Retry.InitialDelay` | 1s | — | Must be positive |
| `Retry.MaxDelay` | 5s | — | Must be at least `InitialDelay` |
| `Retry.JitterFactor` | 0.5 | `0.0` | Range [0.0, 1.0] |
| Inbound `MaxQueueSize` | `0` | `0` | `0` means unbounded |
| Inbound `InactivityTimeout` | `0` | `0` | `0` disables watchdog |
| Outbound `KeepaliveTimeout` | 20s | `0` | `0` disables keepalive |
| Outbound `MaxQueueSize` | `0` | `0` | `0` means unbounded |
| Connection `IncomingBufferSize` | 100 | — | Must be positive |
| Connection `ErrorsBufferSize` | 10 | — | Must be positive |
| Inbound pool `InboxBufferSize` | 256 | — | Must be positive |
| Inbound pool `EventsBufferSize` | 10 | — | Must be positive |
| Outbound pool `InboxBufferSize` | 256 | — | Must be positive |
| Outbound pool `EventsBufferSize` | 10 | — | Must be positive |
| Outbound pool `ErrorsBufferSize` | 10 | — | Must be positive |
See CONFIG.md for the full option reference and defaults table.
## Testing
@@ -341,14 +259,17 @@ go test -race ./...
### Test Helpers for Consumers
The `honeybeetest` package provides mocks and assertions for code that builds on honeybee:
The `honeybeetest` package provides mocks and assertions for code that builds on Honeybee:
- `MockSocket` implements `types.Socket` with pluggable function fields for every method.
- `MockSocket` implements `types.Socket` with pluggable function fields for every method, including `WriteControl` and `SetPongHandler`.
- `MockDialer` implements `types.Dialer` with a pluggable `DialContextFunc`.
- `MockSlogHandler` captures `slog` records for assertions against log output.
- `MockSlogHandler` captures `slog` records for assertions against log output. Child handlers produced via `WithAttrs` share the same record slice as the parent, so attributes added by the logging package appear on the correct records.
- `Eventually(t, condition, msg)` polls a condition until it holds or the test timeout expires.
- `Never(t, condition, msg)` asserts a condition never holds over a short window.
- `ExpectWrite(t, ch, msgType, data)` asserts the next write matches.
- `ExpectWrite(t, ch, msgType, data)` asserts the next write on the channel matches the expected type and payload.
- `ExpectIncoming(t, ch, data)` asserts the next received message matches.
- `AssertLogSequence(t, records, expected)` asserts that a slice of `ExpectedLog` values appears in order within a set of records, using forward-only matching and allowing gaps.
- `FindLogRecord(records, level, msgSnippet)` returns the first record matching the given level and message substring, or nil.
- `AssertAttributePresent(t, record, key, value)` checks that a specific structured attribute is present on a record and equal to the expected value.
Timer-driven paths use short real sleeps (tens of milliseconds). State-transition paths use `Eventually` and complete as soon as the state is observed.
Timer-driven paths use short real sleeps (tens of milliseconds). State-transition paths use `Eventually` and complete as soon as the condition is observed.