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, reference) ->
case authenticate(payload) {
Error(_) ->
socket.Next(model, [
socket.RejectJoin(
reference,
json.object([#("reason", json.string("unauthorized"))]),
),
])
Ok(user) ->
socket.Next(store_user(model, topic, user), [
socket.AcceptJoin(reference, 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(reference)) ->
case decode.run(payload, item_decoder()) {
Ok(item) ->
// process item
socket.Next(model, [
socket.ReplyOk(reference, json.object([#("id", json.string(item.id))])),
])
Error(_) ->
socket.Next(model, [
socket.ReplyError(
reference,
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.EncodingFailed(_) -> 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.

Custom codec encoders return Result(Frame, codec.EncodeError). If one returns Error, beryl closes that socket, stops later effects, and releases its pending reply references and resources. Your application receives Closed(topic, socket.EncodingFailed(error)) for topics that still need their close callback. A failed reply reference cannot be reused.

beryl does not undo earlier effects or generate a fallback reply. It skips further encoders while it completes cleanup. If an encoder fails during a topic's close, that topic may have received its Closed input before the failure; beryl does not deliver it twice.

Match codec.MetadataTooLong(component, byte_size), codec.PayloadNotByteAligned, or codec.EncodingRejected(reason) to inspect the cause. codec.format_encode_error provides log text. Keep secrets out of custom rejection reasons. See Return encoding results for callback migration and binary-helper examples.

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 preserve the exception class (error, exit, or throw) and a depth-limited reason. They also include up to three stack call sites with module, function, arity, and available file/line information. Stack argument values and other stack metadata are omitted. The reason can still contain application data, so do not include secrets in exception reasons.

Each description is copied into a UTF-8 binary of at most 512 Unicode code points (2048 bytes). Half of the text budget is reserved for call sites, so a large reason cannot hide them. Each frame is limited to 80 code points; long names and source locations can be truncated. These limits also bound retained binary memory. beryl does not re-raise the caught exception.

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, and list_groups) 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
Error(group.GroupCallFailed(reason)) -> handle_call_failure(reason)
}

GroupCallFailed distinguishes an unavailable actor, an actor exit during the call, and a timeout. group.broadcast wraps these errors in GroupLookupFailed; it also reports partial-admission errors. A timed-out group mutation may still complete. Notification and broadcast APIs return admission Results; presence mutations also report timeouts and owner exit. See overload handling for migration and close behavior.

PubSub startup, membership operations, broadcasts, and subscriber queries return Result(_, PubSubError). Match the error at the call site. pubsub.describe_error formats the cause for a log message.

case pubsub.join(subscriber, "room:lobby") {
Ok(Nil) -> Nil
Error(reason) -> io.println_error(pubsub.describe_error(reason))
}

A successful broadcast does not confirm delivery. A failed or timed-out join or leave may still take effect; retrying either operation is idempotent. After registry loss, start PubSub again and rejoin with new subscribers.

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.RateLimitTooHigh(_maximum)) ->
panic as "reduce the per-second rate"
Error(beryl.InvalidRateLimitBurst(_burst)) ->
panic as "use a non-negative burst"
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.