Go Client API
The full reference for sdk-go-client covers NewClient, Channel, Segment, the payload helpers, the types and the errors.
go get github.com/useceleris/sdk-go-client
The module requires Go 1.27 or newer, and the package name is celeris. The runtime dependency is github.com/coder/websocket. Clients, channels, segments and subscriptions are safe for concurrent use.
NewClient()
func NewClient(options ClientOptions) (*Client, error)
type ClientOptions struct {
CredentialProvider CredentialProvider
BaseURL string
AllowInsecureLoopback bool
ConnectTimeout time.Duration
ReconnectTimeout time.Duration
PresenceQueryTimeout time.Duration
PublishQueueSize int
DeduplicationWindowSize int
MaximumReconnectAttempts int
}
| Field | Type | Default | Description |
|---|---|---|---|
CredentialProvider | CredentialProvider | — | Required. The SDK calls it for every connection attempt |
BaseURL | string | production endpoint | Only for a local or self-hosted stack |
AllowInsecureLoopback | bool | false | Permits ws:// for loopback hosts. Never set it in production |
ConnectTimeout | time.Duration | 15 s | Covers credential acquisition and the handshake together. 0, or from 1 ms to 15*time.Minute |
ReconnectTimeout | time.Duration | ConnectTimeout | The same combined deadline for each reconnect attempt. 0, or from 1 ms to 15*time.Minute |
PresenceQueryTimeout | time.Duration | 10 s | Deadline for one PresenceList. 0, or from 1 ms to 15*time.Minute |
PublishQueueSize | int | 64 | Maximum number of publishes that wait for room in the writer. A full queue causes ErrBackpressure |
DeduplicationWindowSize | int | 1024 | Number of delivered message IDs that each channel keeps to drop replayed duplicates |
MaximumReconnectAttempts | int | 10 | Failed reconnect attempts in one budget before the channel stops in failed. 0, or from 1 to 100 |
NewClient performs no network work. A zero option means its default. NewClient returns ErrConfiguration for an omitted provider, a negative size, a timeout that is not 0 or from 1 ms to 15 minutes, and a MaximumReconnectAttempts value that is not from 0 to 100. BaseURL is the realtime socket endpoint, not your HTTP credential endpoint. It must be wss://, or ws:// to localhost or a loopback address with AllowInsecureLoopback, and it must carry no credentials, query string or fragment. The SDK dials with its own HTTP client, which honors the proxy environment variables. Changes to http.DefaultTransport do not affect it.
Client
func (client *Client) Channel(reference string) (*Channel, error)
Channel is side-effect free and returns a new connection handle on every call. A reference is 1 to 255 characters of [A-Za-z0-9_-]. Anything else returns ErrConfiguration.
Channel
func (channel *Channel) State() ChannelState
func (channel *Channel) Connect(ctx context.Context) error
func (channel *Channel) Close()
func (channel *Channel) Segment(segmentID string) (*Segment, error)
func (channel *Channel) DefaultSegment() *Segment // Segment("default")
func (channel *Channel) Events() ChannelEventHandler
type ChannelState string
const (
StateIdle ChannelState = "idle"
StateConnecting ChannelState = "connecting"
StateConnected ChannelState = "connected"
StateReconnecting ChannelState = "reconnecting"
StateFailed ChannelState = "failed"
StateClosing ChannelState = "closing"
StateClosed ChannelState = "closed"
)
| Member | Notes |
|---|---|
State | The current state. It can change as soon as it is read |
Connect | Returns ErrOperationInProgress if connecting, connected or reconnecting, ErrNotConnected once closing or closed. An initial failure both returns and sets failed. Canceling ctx abandons the attempt and sets failed. |
Close | Idempotent and terminal. Returns within about 5 s, and every call waits for the same close. Fails waiting publishes and queries with ErrCancelled. Ends every segment handle with the channel. |
Segment(id) | Side-effect free. The id is required: non-empty, no CR or LF, valid UTF-8. |
DefaultSegment() | The "default" segment every connection joins automatically. |
You can recover a failed channel when you call Connect again. You cannot recover a closed channel. The SDK does not automatically retry an initial connection failure. See Reconnection and Recovery for triggers, deadlines, retry budgets and restored subscriptions.
A successful Connect joins default. DefaultSegment().OnMessage(listener) receives its messages without Subscribe(). Read and write permissions still apply, and default membership includes no other segment and no presence subscription. The automatic join also happens on reconnect.
Segment
func (segment *Segment) ID() string
func (segment *Segment) Subscribe() (*Subscription, error)
func (segment *Segment) SubscribePresence() (*Subscription, error)
func (segment *Segment) OnMessage(listener func(payload []byte, metadata MessageMetadata)) (remove func())
func (segment *Segment) OnPresence(listener func(PresenceEvent)) (remove func())
func (segment *Segment) Publish(ctx context.Context, payload []byte) error
func (segment *Segment) PublishWithMessageID(ctx context.Context, payload []byte, messageID string) error
func (segment *Segment) PresenceList(ctx context.Context, page, perPage int32) (PresencePage, error)
func (subscription *Subscription) Cancel() // idempotent
| Method | Notes |
|---|---|
Subscribe | A real server operation, restored after every reconnect. Never sent for "default", which is joined automatically. Returns ErrNotConnected once the channel is closing or closed. |
OnMessage / OnPresence | Return a function that removes that listener. Handles for the same segment share one listener set. A nil listener panics. The payload is shared by every listener of the delivery: treat it as read-only. |
Publish | Returns on local acceptance only, with a generated message ID. An empty payload is valid. Returns ErrConfiguration for a command over 2 MiB encoded. The server enforces your plan's size limit afterwards. Does not join the connection to the segment. To receive messages, call Subscribe. With read access only, the server does not accept the publish and sends a PermissionDeniedError through OnError. While the channel is reconnecting, the publish waits in the publish queue. It goes out after the reconnect, after the restored subscriptions. Returns ErrNotConnected before the first connect, during the first connect, and when the channel is failed, closing or closed. Returns ErrBackpressure when the publish queue is full. Canceling ctx withdraws a publish not yet being written. |
PublishWithMessageID | Publish with your own ID: non-empty, no CR or LF. Receivers drop a repeated ID within their deduplication window. |
SubscribePresence | Tells the server to send join and leave events. It does not join the segment for messages, and it does not keep the segment. The watcher is not announced or listed because of it. Returns ErrNotConnected once the channel is closing or closed. |
PresenceList | One in flight per channel. A second returns ErrOperationInProgress. page is at least 1, perPage 1 to 100, both validated rather than clamped. A query the server refuses returns a *ServerError with SubType "PRES_LIST" at once. A timeout or cancellation frees the slot and leaves the connection up. Inside a listener it always times out. |
Payload helpers
func TextPayload(text string) []byte
func JSONPayload(value any) ([]byte, error)
func ReadText(payload []byte) (string, error)
func ReadJSON[T any](payload []byte) (T, error) // decodes into T; does not validate
func NewPayloadCodec[T any](encode func(T) ([]byte, error), decode func([]byte) (T, error)) (PayloadCodec[T], error)
func (codec PayloadCodec[T]) EncodePayload(value T) ([]byte, error)
func (codec PayloadCodec[T]) ReadPayload(payload []byte) (T, error)
TextPayload replaces invalid UTF-8 with U+FFFD. JSONPayload writes compact JSON with encoding/json. It does not escape HTML and does not add a final newline. It returns ErrConfiguration for NaN, infinities, channels, functions, complex numbers and cyclic structures. ReadText and ReadJSON return ErrConfiguration for invalid UTF-8, invalid JSON, or JSON that does not fit T. NewPayloadCodec returns ErrConfiguration for a nil function. The SDK returns errors from your own encode or decode unchanged. No size check happens here: Publish enforces the encoded-command bound.
Types
type MessageMetadata struct {
TokenReference string
SegmentID string
MessageID string // always present: the publisher's, or one the server assigned
Timestamp int64 // Unix milliseconds
}
type PresenceEvent struct {
SegmentID string
TokenReference string
ConnectionID string
Joined bool // false: the connection left
Timestamp int64
}
type PresencePage struct {
SegmentID string
Total int32
PerPage int32
CurrentPage int32
From int32 // From > To past the last page
To int32
Connections []PresenceConnection
}
type PresenceConnection struct {
TokenReference string
ConnectionID string
Timestamp int64
}
type ServerNotice struct {
Timestamp int64
Payload []byte
}
type RecoveryEvent struct {
RetryIndex int
PossibleGaps bool // always true
PossibleDuplicates bool // always true
}
type CredentialRequest struct {
ChannelReference string
Reconnect bool // false for the attempt Connect starts
DisconnectedAt time.Time // zero unless Reconnect
ReplayLookback time.Duration // the outage plus 5 s; zero unless Reconnect
}
type Credentials struct {
Payload string `json:"payload"`
Signature string `json:"signature"`
}
type CredentialProvider func(ctx context.Context, request CredentialRequest) (Credentials, error)
Presence figures are signed 32-bit values as received. Timestamps are signed 64-bit Unix milliseconds. ReplayLookback is whole milliseconds, at most 4294967295 ms. Credentials implements fmt.Stringer, fmt.Formatter and slog.LogValuer, so it prints as redacted with every verb when you format it directly. fmt prints a struct's unexported fields field by field, so do not keep credentials in a struct you print. A provider must honor ctx and return non-empty, valid UTF-8 values. Empty or invalid values return ErrConfiguration. The SDK reports any error the provider returns, or a panic, as ErrTransport.
ChannelEventHandler
func (handler ChannelEventHandler) OnStateChange(listener func(ChannelState)) (remove func())
func (handler ChannelEventHandler) OnRecovery(listener func(RecoveryEvent)) (remove func())
func (handler ChannelEventHandler) OnNotice(listener func(ServerNotice)) (remove func())
func (handler ChannelEventHandler) OnError(listener func(error)) (remove func())
func (handler ChannelEventHandler) OnMessage(listener func(payload []byte, metadata MessageMetadata)) (remove func())
Each registration returns a function that removes it. Listeners run one at a time, in the order events happened, never under an SDK lock, and with no queue on your side. A slow listener delays delivery and does not grow a backlog. A listener can call back into the channel. The events its calls cause follow once it returns. The SDK recovers a listener that panics and reports it through OnError. The SDK does not recover a panic in an error listener. A recovery event follows the connected state change. It does not signal replay completion.
OnMessage receives all messages from all segments of the connection. It gets each message after the segment listeners, and it gets each message ID one time. All listeners of a message get the same payload. Do not change the payload.
The function that OnMessage returns removes only this channel listener. The other channel listeners and the segment listeners continue to receive messages. The subscriptions do not change, and the SDK does not send a message to the server. When you call it again, it has no effect.
OnError receives failures that no caller waits for: a *ServerError, or an *Error such as an undecodable frame, a reconnect that gave up, or a listener that panicked. OnNotice delivers raw server prose, such as subscription acknowledgments and refusals. It carries no segment id and no correlation id. Do not parse it.
Errors
type ErrorCode string // a sentinel: errors.Is(err, ErrTimeout)
type Error struct {
Code ErrorCode
Message string
Field string // set only for ErrProtocol
Offset int // zero-based byte offset, set only for ErrProtocol
}
const (
ErrConfiguration ErrorCode = "Configuration"
ErrTimeout ErrorCode = "Timeout"
ErrCancelled ErrorCode = "Cancelled"
ErrTransport ErrorCode = "Transport"
ErrNotConnected ErrorCode = "NotConnected"
ErrBackpressure ErrorCode = "Backpressure"
ErrOperationInProgress ErrorCode = "OperationInProgress"
ErrDeliveryUnknown ErrorCode = "DeliveryUnknown"
ErrProtocol ErrorCode = "ProtocolError"
)
Every error the SDK detects is an *Error. Its message says what failed and which rule or limit it broke, such as Invalid channel reference. Must not be empty.. The message never carries your input, credentials or server bytes, and it never wraps a cause that holds them. errors.Is(err, celeris.ErrTimeout) matches by code. Errors the server sends are a *ServerError instead. Never match on message text.
| Sentinel | Returned when |
|---|---|
ErrConfiguration | The call is wrong, or a credential provider returned invalid credentials. Fix the code. Do not retry. |
ErrTimeout | The connect deadline elapsed, a presence query timed out, or your context's deadline passed |
ErrCancelled | Your context was cancelled, or the channel closed during a connect, a queued publish or a query |
ErrTransport | Handshake or socket failure, a credential provider that failed or panicked, a listener that panicked, or a connection loss during a presence query |
ErrNotConnected | A publish before the first connect, during the first connect, or in failed, closing or closed. A presence query while not connected. A Connect, Subscribe or SubscribePresence once the channel is closing or closed |
ErrBackpressure | The publish queue (PublishQueueSize, 64 by default) is full, with the message "The publish queue is full (size N). Retry once some publishes have gone out." A presence query also returns it while the writer is full or paused after a rate limit |
ErrOperationInProgress | A concurrent Connect, or a second PresenceList |
ErrDeliveryUnknown | The socket failed, or your context ended, while writing a publish: acceptance genuinely unknown |
ErrProtocol | One received frame could not be decoded. Field and Offset locate the failure |
ErrProtocol means the SDK could not decode one message. The SDK drops that message and reports the error, and the connection stays up. The SDK silently skips a command that this SDK version does not recognize, so a newer server cannot break a deployed client.
There is no authentication code. The WebSocket handshake status is not exposed to the SDK, so it reports a refused handshake as ErrTransport and does not guess.
ServerError
type ServerErrorType string
const (
ParserError ServerErrorType = "ParserError"
SendError ServerErrorType = "SendError"
PermissionDeniedError ServerErrorType = "PermissionDeniedError"
RateLimitError ServerErrorType = "RateLimitError"
MessageSizeLimitError ServerErrorType = "MessageSizeLimitError"
InternalError ServerErrorType = "InternalError"
)
type ServerError struct {
Type ServerErrorType // one of the above, or a type a newer server adds
SubType string // the command it answers, e.g. "PUB"; "" when none
Message string // the server's own text, unchanged
Resource any // what that command names: nil, string, int32, int64 or []any
}
A ServerError is an error the server sent, with every field as sent, and the connection stays up. It has no Code: match it with errors.AsType. It arrives through OnError, after the call that caused it, except for a presence query error, which PresenceList returns instead. Keep a default branch for types a newer server adds. Check Resource's type before you use it.
| Error | SubType | Resource |
|---|---|---|
| A permission denial for a publish, subscribe or presence subscription | "PUB", "SUB", "UNSUB", "PRES_SUB" or "PRES_UNSUB" | The segment id |
| A refused or failed presence query | "PRES_LIST" | The query's request id, which the SDK sets |
| Anything else | "" | nil |
Type | Sent when |
|---|---|
PermissionDeniedError | The token lacks access to what the command tried |
MessageSizeLimitError | A command exceeded your plan's size limit |
RateLimitError | A per-second, per-hour, per-month or per-connection message limit was exceeded. The SDK pauses, then resends recent commands |
ParserError | The server could not parse a command |
SendError | The server failed to send |
InternalError | A fault inside the server, such as a failed presence read |
Limits
| Limit | Value |
|---|---|
| Outbound command | 2 MiB encoded, rejected before any write |
| Encoded command, plan limit | Set by your plan (Limits). Counts the framing, the identifiers and the payload. Enforced by the server |
| Writer bounds | 64 pending commands, 2 MiB of unsent data. Commands that do not fit wait. A subscription change goes ahead of queued publishes, but not ahead of an earlier publish to its own segment |
| Rate-limit recovery | Pause of 1 s plus growing jitter (at most 31 s), then a resend of the last 2 s of commands (at most 64 publishes, each once). After 8 limits in a row, dropped subscriptions are retried after 1 min, doubling to 1 h |
| Duplicate filtering | DeduplicationWindowSize message IDs per channel, 1024 by default. The window stays across reconnects and clears on Connect |
| Publish queue | PublishQueueSize publishes, 64 by default. Publishes that a dropped socket did not start to write go back to the front of the queue, even when the queue is full |
| Reconnect | MaximumReconnectAttempts failed attempts per budget, 10 by default, from 1 to 100. Full-jitter delays up to 30 s. Only ErrTransport and ErrTimeout failures count and retry. Other failures, such as invalid credentials, stop recovery at once. The budget resets at a drop after at least 60 s connected |
| Connect deadline | ConnectTimeout, 15 s by default, covering credentials and handshake. A reconnect attempt uses ReconnectTimeout, defaulting to it |
| Presence query | PresenceQueryTimeout, 10 s by default, one in flight per channel |
| Close | About 5 s graceful, then forced |
| Heartbeat | A WebSocket ping every 20 s keeps the connection open while a listener runs |
| Dead connection | A ping unanswered for 15 s of reading time, excluding time spent in listeners, means the path is dead: noticed within about 35 s, then recovered like any drop. TCP keepalive is a backstop |
The SDK does not check the size of received messages. They are already in memory when they arrive, so the SDK processes whatever the server sends. A publish rejected for your plan's size limit still counts toward your usage. A local encoded-command rejection sends nothing.
What this SDK will not do
The SDK does not do these tasks:
- Confirm delivery. There are no receipts anywhere in the protocol.
- Keep a durable offline queue. Publishes wait in an in-memory queue only while the channel is
reconnecting. The SDK resends publishes only after a rate limit, once each, with their original ID. - Provide durable history or global ordering. Replay is a bounded window, not a cursor.
- Sign anything. The signer lives in sdk-go-server.