Skip to content

Handle errors

beryl reports errors to your application and clients. Your application decides how to respond.

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"}.

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.

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"))]),
),
])
}

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.

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.

beryl catches panics in init and update instead of stopping the socket's runtime actor. The result depends on where the panic occurs:

Crash siteEffect
initThe connecting socket is not registered; the connection is closed
update on JoinThe join is rejected (response: {"reason": "join crashed"}); the socket survives
update on Message/BinaryOnly that topic is closed (phx_error); other topics survive
update on InfoThe socket is torn down
update on ClosedLogged; 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 callbackEffect
joinRejects only that join
on_messageCloses only that topic; on_terminate still runs
on_infoCloses only that topic; on_terminate still runs
on_terminateLogs 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.

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".

LimitApplies toEnforced atConfig function
frame_ratePer connection, every complete frameTransport, before decodeberyl.with_frame_rate
message_ratePer socket, decoded non-join trafficRuntimeberyl.with_message_rate
join_ratePer socket, joinsRuntimeberyl.with_join_rate
channel_ratePer socket+topicRuntimeberyl.with_channel_rate
topic_ratesFirst matching patternRuntimeberyl.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 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.

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.

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.

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.