Handle errors
beryl reports errors to your application and clients. Your application decides how to respond.
Rejected joins
Section titled “Rejected joins”Return a RejectJoin effect to reject a client. The error payload is sent back as a phx_reply with status: "error":
fn update(model: Model, input: Input(Message)) -> Next(Model) { case input { socket.Join(topic, payload, ref) -> case authenticate(payload) { Error(_) -> socket.Next(model, [ socket.RejectJoin( ref, json.object([#("reason", json.string("unauthorized"))]), ), ]) Ok(user) -> socket.Next(store_user(model, topic, user), [ socket.AcceptJoin(ref, option.None), ]) } // ... }}The client sees:
["1", "1", "room:lobby", "phx_reply", {"status": "error", "response": {"reason": "unauthorized"}}]After rejection, the client stays connected but does not join the topic. If the
payload has a reason field, Phoenix clients pass it to the
.join().receive("error", ...) callback.
With beryl/channel, return channel.reject(reason) from the handler's
join callback. A topic no handler matches is rejected automatically with
{"reason": "unmatched topic"}.
Reject a connection during authentication
Section titled “Reject a connection during authentication”on_connect in the transport config rejects the WebSocket upgrade before any topic join occurs. Return Error(server.ConnectRejected) to send an HTTP 403 response:
import beryl/transport/server
let config = server.default_config("/socket/websocket") |> server.with_on_connect(fn(request) { case extract_token(request) { Ok(_) -> Ok([]) // Allow; no connect metadata Error(_) -> Error(server.ConnectRejected) // → HTTP 403, connection refused } })The client never receives a WebSocket handshake and cannot send any messages.
Handle malformed messages
Section titled “Handle malformed messages”When configured with wire.phoenix_codec(), beryl parses Phoenix protocol
arrays: [join_ref, ref, topic, event, payload]. With any codec, it drops
frames that the active codec cannot decode and does not send an error.
Malformed frames are protocol violations.
To report payload decode errors to a client, decode the Dynamic payload with
gleam/dynamic/decode and return an explicit ReplyOk or ReplyError:
socket.Message(_topic, "create_item", payload, option.Some(ref)) -> case decode.run(payload, item_decoder()) { Ok(item) -> // process item socket.Next(model, [ socket.ReplyOk(ref, json.object([#("id", json.string(item.id))])), ]) Error(_) -> socket.Next(model, [ socket.ReplyError( ref, json.object([#("reason", json.string("invalid_payload"))]), ), ]) }Unmatched topics
Section titled “Unmatched topics”If update does not accept a phx_join topic, reject it with a catch-all
Join branch that returns RejectJoin. If update ignores the join, beryl's
fail-closed default rejects it.
Messages pushed to a topic the socket never joined get an automatic error
reply with response: {"reason": "unmatched topic"} when they include a
reference, matching Phoenix. Messages without a reference are dropped.
Heartbeat timeouts
Section titled “Heartbeat timeouts”When a client goes silent beyond heartbeat_timeout_ms, the runtime evicts the socket. Every joined topic receives a Closed event with HeartbeatTimeout:
socket.Closed(topic, reason) -> { case reason { socket.HeartbeatTimeout -> { // Clean up: remove from presence, release locks, etc. Nil } socket.Normal -> Nil socket.Shutdown -> Nil socket.Errored(_) -> Nil socket.AdmissionRejected(_) -> Nil } socket.Next(prune(model, topic), [])}The client-visible effect is that the WebSocket connection is closed from the server side. Phoenix JS clients will attempt to reconnect automatically.
When init or update panics
Section titled “When init or update panics”beryl catches panics in init and update instead of stopping the socket's
runtime actor. The result depends on where the panic occurs:
| Crash site | Effect |
|---|---|
init | The connecting socket is not registered; the connection is closed |
update on Join | The join is rejected (response: {"reason": "join crashed"}); the socket survives |
update on Message/Binary | Only that topic is closed (phx_error); other topics survive |
update on Info | The socket is torn down |
update on Closed | Logged; the close completes anyway |
Crash descriptions are depth-limited and truncated before logging so client-triggered crashes cannot bloat log metadata.
This behavior applies only to the listed callbacks. A callback panic in the socket actor would close every topic on that socket. beryl discards the failed callback result and closes only the affected join, topic, or socket when safe. Other faults stop that socket actor; the router closes the connection and removes its entries. A router fault invokes supervision and disconnects all sockets. See What closes after a callback panic for the trade-off.
The channel layer applies the same results to these callbacks:
| Channel callback | Effect |
|---|---|
join | Rejects only that join |
on_message | Closes only that topic; on_terminate still runs |
on_info | Closes only that topic; on_terminate still runs |
on_terminate | Logs the panic and completes the close; the callback's actions are lost |
Each channel runs in its own worker process. A panic keeps the state from
before the callback, so on_terminate still uses that state. If the worker
stops unexpectedly, the runtime closes the topic with phx_error. It cannot
run on_terminate because the worker held the channel state. See
When callbacks panic.
Rate limits and dropped traffic
Section titled “Rate limits and dropped traffic”When a client exceeds a rate limit, beryl drops the frame or message. It
does not send an error. An over-rate join is the exception. It receives an
error reply with reason: "rate_limited".
| Limit | Applies to | Enforced at | Config function |
|---|---|---|---|
frame_rate | Per connection, every complete frame | Transport, before decode | beryl.with_frame_rate |
message_rate | Per socket, decoded non-join traffic | Runtime | beryl.with_message_rate |
join_rate | Per socket, joins | Runtime | beryl.with_join_rate |
channel_rate | Per socket+topic | Runtime | beryl.with_channel_rate |
topic_rates | First matching pattern | Runtime | beryl.with_topic_rate |
The frame and message buckets are independent. Joins consume frame tokens like
every inbound frame, never message tokens, and use join_rate for runtime
accounting.
let config = beryl.config(wire.phoenix_codec()) |> beryl.with_frame_rate(per_second: 150, burst: 300) |> beryl.with_message_rate(per_second: 100, burst: 200) |> beryl.with_join_rate(per_second: 5, burst: 10) |> beryl.with_channel_rate(per_second: 50, burst: 100) |> beryl.with_topic_rate(pattern: "cursor:*", per_second: 30, burst: 60)with_topic_rate overrides the global channel_rate for matching topics. Use
it to give a high-rate namespace, such as live cursors, more capacity than
chat. A non-positive per_second makes matching topics unlimited,
even when a global channel limit is configured, and allocates no per-topic
bucket. If you need to inform the client that it has been rate-limited,
implement application-level tracking in update and return an explicit
ReplyError.
Group operation errors
Section titled “Group operation errors”Group operations (create, delete, add, remove, topics) return Result(_, GroupError):
case group.create(groups, name) { Ok(Nil) -> Nil Error(group.GroupAlreadyExists(_)) -> Nil // idempotent: treat as success if desired Error(group.GroupNotFound(_)) -> Nil // shouldn't happen for create}group.broadcast returns lookup or partial-admission errors. Like other group
operations, its topic lookup still panics if the groups actor is unavailable
or exceeds its configured call timeout. Notification and broadcast APIs
return admission Results; presence mutations also report timeouts and owner
exit. See overload handling for migration and close behavior.
Startup configuration errors
Section titled “Startup configuration errors”beryl.child_spec validates configuration before returning the child spec:
case beryl.child_spec(config, init: init, update: update) { Ok(#(sockets, child_specification)) -> add_to_supervisor(sockets, child_specification) Error(beryl.HeartbeatTimeoutTooLow(_minimum)) -> // heartbeat_timeout_ms below 2 would silently disable eviction panic as "fix the heartbeat config" Error(beryl.InvalidTopicPattern(pattern, topic.EmptyTopic)) -> panic as { pattern <> " is empty" } Error(beryl.InvalidTopicPattern(pattern, topic.InvalidFormat(detail))) -> panic as { pattern <> ": " <> detail }}InvalidTopicPattern contains beryl/topic.TopicError instead of a string.
Match each TopicError variant explicitly. If an API update adds a variant,
the compiler identifies the matches that need an additional branch.
channel.child_spec validates the handler table first and reports
InvalidPattern(pattern, reason), DuplicatePattern(pattern), or
InvalidConfig(beryl.ConfigError). InvalidPattern nests
beryl/topic.TopicError; handle its variants explicitly in the same way.
Typed sender delivery is not confirmed
Section titled “Typed sender delivery is not confirmed”socket.notify sends a typed message to socket update as an Info event. If
the socket disconnected, beryl drops the message and returns no error:
// This is always Nil, even if the socket is gone.socket.notify(sender, MyMessage)If you need delivery confirmation, have the Info branch of update send an
acknowledgment to the sending process.
Client error messages
Section titled “Client error messages”With wire.phoenix_codec(), error responses take these shapes:
Join rejected:
["1", "1", "room:lobby", "phx_reply", {"status": "error", "response": {}}]Channel error:
["1", "1", "room:lobby", "phx_error", {}]Channel closed:
["1", "1", "room:lobby", "phx_close", {}]Terminal events repeat the topic's join ref in both reference slots. Phoenix
client libraries handle phx_error and phx_close. The client marks the
channel as errored or closed and can try to rejoin.
Related guides
Section titled “Related guides”- Troubleshooting: diagnose connection failures, missed messages, and authentication problems
- WebSocket Transport guide: configure
on_connectauthentication - Supervision guide: understand runtime supervision and callback panics
