Skip to main content

Errors & Codes

Every exception the SDK raises, every WebSocket close code, every server-emitted error code, and every event type — with what to do about each.

Exception hierarchy

Every exception descends from MeteredRealtimeError, so a single except MeteredRealtimeError is your catch-all. Branch on isinstance and on the stable .code attributes — never on str(err).

MeteredRealtimeError
├── SignallingError
│ ├── SignallingConnectError (.close_code, .close_reason)
│ ├── ServerRequestError (.code, .request_id)
│ ├── AckTimeoutError (.request_id, .timeout)
│ └── DisconnectedError
├── MeteredPeerSendError (.code)
├── MeteredPeerOversizedError (.size, .cap)
├── MeteredPeerStateError (.code, .method, .current_state)
├── MeteredPeerReplaceTrackError (.succeeded, .failed)
└── DataChannelOverflowError (.queued, .cap)
Stable contract — branch on type, not text

The SDK guarantees these as part of its SemVer-stable surface:

  • Exception class names (SignallingConnectError, MeteredPeerSendError, etc.)
  • err.code literal values (e.g. "not_joined", "channel_reserved")
  • Exception attribute names (err.size, err.cap, err.succeeded, etc.)

The SDK does not guarantee:

  • str(err) text — it's human-readable; format may change between minor versions.
  • Tracebacks.

Use isinstance(err, ...) / err.code to branch. Never if "..." in str(err) — that will break across upgrades.

SignallingConnectError

Subclass of SignallingError. Raised by await client.connect() when the WebSocket closes before the server welcome arrives — i.e. the handshake itself failed.

from metered_realtime import SignallingConnectError, WsCloseCode

try:
await client.connect()
except SignallingConnectError as e:
print("close code:", e.close_code)
print("reason:", e.close_reason)
if e.close_code == WsCloseCode.INVALID_TOKEN:
show_login_ui()

Attributes: close_code: int, close_reason: str.

Note: close_reason is server-controlled text. Sanitize before showing it in a UI. The fixed-format str(error) is safe to log directly.

ServerRequestError

Subclass of SignallingError. Raised from subscribe / unsubscribe / publish / send when the server returns an error frame correlated to that request. This is how per-request rejections surface — at the call site, not on an event.

from metered_realtime import ServerRequestError

try:
await client.subscribe("rooms/secret")
except ServerRequestError as e:
if e.code == "channel_not_authorized":
... # JWT's `channels` claim doesn't allow it
elif e.code == "channel_reserved":
... # channel starts with _metered/ etc.
elif e.code == "over_message_quota":
... # publish/send blocked until the period rolls over

Attributes: code: ErrorCode (stable literal — see Server-emitted error codes), request_id: str | None.

AckTimeoutError

Subclass of SignallingError. Raised when a request goes on the wire but no ack arrives within the ack timeout (15 s).

Attributes: request_id: str, timeout: float.

DisconnectedError

Subclass of SignallingError. Raised from an in-flight subscribe / unsubscribe / publish / send when the connection drops before the server confirmed it. The server may or may not have processed the frame — retry after reconnect if the operation isn't idempotent in your app.

A call made while already disconnected raises the base SignallingError ("not connected") instead — the frame never left the client. DisconnectedError specifically means the frame was sent and then the connection died.

MeteredPeerSendError

Raised by peer.send(...), peer.join(...), and remote.send(...) for state and argument failures.

from metered_realtime import MeteredPeerSendError

try:
await peer.send(...)
except MeteredPeerSendError as e:
if e.code == "not_joined":
... # await peer.join(channel) first
elif e.code == "reserved_channel":
... # channel starts with _metered/ etc.
elif e.code == "invalid_args":
... # data was None or peer_id malformed
elif e.code == "self_send":
... # you can't send to your own peer_id

Attribute: code: Literal["reserved_channel", "not_joined", "invalid_args", "self_send"].

MeteredPeerOversizedError

Raised by the peer-layer send paths (peer.send(...), peer.send_to(...), and by peer.add_stream(...) / peer.add_track(...) when the metadata bag is too large). The size check runs before the wire send / before insertion into the tracking map, so over-cap metadata can't loop through reconcile re-sends.

from metered_realtime import MeteredPeerOversizedError

try:
await peer.send(huge)
except MeteredPeerOversizedError as e:
print(f"payload {e.size} bytes, server cap {e.cap}")

Attributes: size: int (attempted serialized byte length), cap: int (server cap).

If you need to send larger payloads, chunk them at the application layer or use a P2P DataChannel for the bulk transfer (P2P has different size limits — see the DataChannel reference).

SignallingClient.publish / send do not raise this — they don't run a client-side size check. An over-cap payload there is rejected server-side and surfaces as a 1009 (MessageTooBig) close. The size check is a MeteredPeer-layer feature.

MeteredPeerStateError

Raised by add_stream, add_track, remove_stream, remove_track, replace_track, and join when called in the wrong lifecycle state, or by add_track when the same track is added with a conflicting stream.

from metered_realtime import MeteredPeerStateError

try:
peer.add_track(track, stream)
except MeteredPeerStateError as e:
if e.code == "invalid_state":
# e.method tells you which call failed: "add_stream" | "add_track" |
# "remove_stream" | "remove_track" | "replace_track" | "join"
# e.current_state is the state the instance was in (e.g. "closed")
if e.current_state == "closed":
rebuild_peer()
elif e.code == "track_already_attached":
# Track is already attached to a different stream.
# Pick one stream association per track.
...

Attributes: code: Literal["invalid_state", "track_already_attached"], method: str, current_state: str | None.

"invalid_state" is the catch-all "your peer is in the wrong lifecycle state for this call" error — typically you tried to add or remove media after close() or before join(). e.method identifies the API call, e.current_state gives the state at the time of the call.

"track_already_attached" only fires from add_track (and the per-track loop inside add_stream) when the same track reference is added with a different stream argument. The same-stream re-add is idempotent; only the different-stream collision raises.

MeteredPeerReplaceTrackError

Raised by await peer.replace_track(old_track, new_track) when the swap succeeds on some peers but fails on others. Carries both lists so you can converge surgically.

from metered_realtime import MeteredPeerReplaceTrackError

try:
await peer.replace_track(old_cam, new_cam)
except MeteredPeerReplaceTrackError as e:
# e.succeeded: tuple[str, ...] of peer_ids already on new_cam
# e.failed: tuple[ReplaceTrackFailure, ...] still on old_cam
for failure in e.failed:
print(f"failed for {failure.peer_id}:", failure.err)

Attributes: succeeded: tuple[str, ...], failed: tuple[ReplaceTrackFailure, ...].

ReplaceTrackFailure is a frozen dataclass with peer_id: str and err: Exception.

DataChannelOverflowError

Raised by DataChannel.send(...) when the wrapper's send queue exceeds its cap. Means your producer is faster than the network.

Attributes: queued: int, cap: int.

WebSocket close codes

WsCloseCode is an IntEnum — its members compare equal to their integer values, so you can branch against either.

from metered_realtime import WsCloseCode, Disconnected

@client.on(Disconnected)
def _(ev: Disconnected) -> None:
if ev.code == WsCloseCode.ADMIN_DISCONNECT:
show_kicked_ui()
CodeMemberMeaningWhat the SDK doesWhat you should do
1001GOING_AWAYServer shutting down for deployReconnects on normal backoffNothing required. The preceding GoingAway event carries a retry_after_ms hint if you want to delay manually.
1006(no member)Abnormal close — network died, or the handshake was rejectedReconnectsShow "reconnecting" UI
1008POLICY_VIOLATIONSent a binary frame, or invalid JSON shapeReconnectsCheck your wire-format compliance — this is a client bug
1009MESSAGE_TOO_BIGPayload exceeded the server capReconnectsOn the MeteredPeer layer, catch MeteredPeerOversizedError before send
4000CLIENT_INACTIVITYThe SDK's own inactivity watchdog fired (no frames within inactivity_timeout)ReconnectsNothing — this is the SDK self-healing on a stuck WS. The server never emits it.
4001INVALID_TOKENJWT signature wrong, malformed, or wrong keyTerminal — no retryRefresh the user's auth, mint a new token
4002TOKEN_EXPIREDJWT's exp claim is in the pastReconnects (token_provider refresh)Make sure token_provider() returns a fresh JWT
4003CHANNEL_NOT_AUTHORIZEDChannel outside the JWT's channels claimTerminal — no retryFix your channel list at JWT-minting time
4010OVER_CONCURRENT_LIMITAt your plan's concurrent-connection capReconnects, but with a ≥30 s backoff floorRaise your plan limit, or wait — retrying every 500 ms just hammers the server
4011OVER_MESSAGE_RATEPer-connection rate limit hitReconnects (fresh bucket)Throttle your producer; this is per-second abuse defense, not a billing cap
4012ACCOUNT_SUSPENDEDAccount-level kill switchTerminal — no retryDirect the user to billing / contact support
4020ADMIN_DISCONNECTDisconnected via the REST APITerminal — no retryShow "you were disconnected" + login button

The terminal set is exactly {4001, 4003, 4012, 4020} — for these the SDK does not even attempt a retry (it transitions straight to "closed" and emits a terminal Disconnected with will_reconnect=False). Code 4002 (TOKEN_EXPIRED) is not terminal: the SDK reconnects, re-invoking your token_provider() for a fresh JWT first. Code 4010 (OVER_CONCURRENT_LIMIT) reconnects but forces a ≥30 s backoff floor on the next attempt.

Members present on WsCloseCode: GOING_AWAY (1001), POLICY_VIOLATION (1008), MESSAGE_TOO_BIG (1009), CLIENT_INACTIVITY (4000), INVALID_TOKEN (4001), TOKEN_EXPIRED (4002), CHANNEL_NOT_AUTHORIZED (4003), OVER_CONCURRENT_LIMIT (4010), OVER_MESSAGE_RATE (4011), ACCOUNT_SUSPENDED (4012), ADMIN_DISCONNECT (4020). Code 1006 has no member — the raw integer reaches you as Disconnected.code.

Server-emitted error codes

ErrorCode is the stable literal set of machine-readable codes the server puts in its error frames:

ErrorCode = Literal[
"malformed_message",
"unknown_type",
"invalid_channel",
"invalid_peer_id",
"channel_not_authorized",
"channel_reserved",
"channel_limit_exceeded",
"peer_not_found",
"missing_data",
"action_not_permitted",
"over_message_quota",
]

These appear in two places:

  • As ServerRequestError.code — when the error frame is correlated to a request you made (subscribe / publish / send / …). The awaited call raises; match request_id if you need to.
  • On the ServerError event — when the error frame is not correlated to a pending request. See the SignallingClient reference.
from metered_realtime import ServerError

@client.on(ServerError)
def _(ev: ServerError) -> None:
print(ev.code, ev.message, ev.request_id)
CodeTriggered byCustomer fix
malformed_messageInvalid JSON, missing required fieldClient bug — file an SDK issue if you hit this
unknown_typeUnrecognized message typeSame
invalid_channelChannel name violates the wire-format rules (length, charset)Validate channel names client-side before subscribe
invalid_peer_idpeer_id violates wire-format rulesValidate peer ids at JWT-mint time
channel_not_authorizedChannel outside JWT's channels claimMint a JWT that includes the channel
channel_reservedChannel starts with _metered/, _internal/, _system/Pick a different prefix
channel_limit_exceededSubscribing past the per-connection channel capOpen a second SignallingClient or unsubscribe from inactive channels
peer_not_foundsend target isn't online on this appRace condition (peer left between your decision and your send) — typically just drop the send
missing_datapublish / send without dataClient bug — data is required
action_not_permittedKey's permissions don't grant this actionMint a JWT with the action in its permissions
over_message_quotaPeriod message quota exhausted; connection stays openDirect user to billing, or wait until the period rolls over

Note that over_message_quota is not a disconnect — your connection stays open, but any further publish / send calls raise ServerRequestError(code="over_message_quota") until your billing period rolls over. Quota limits don't kill the connection; they only block message-emitting actions.

The parser rejects codes outside this set (they arrive as a logged malformed frame rather than as an event), so a correlated request rejects with one of the literals above. Branch with equality checks (ev.code == "..."); if you match, keep a wildcard arm.

Events

Events are typed frozen dataclasses subclassing Event, dispatched to handlers registered with client.on(EventType, handler) (or streamed via client.events(EventType)). They are not string names.

Signalling-layer events

EventFields
Connectedpeer_id: str, server_time: int, expires_at: int \| None, is_reconnect: bool, max_message_size: int, ice_servers: tuple[IceServerConfig, ...] \| None
Disconnectedcode: int, reason: str, will_reconnect: bool
StateChangefrom_: str, to: str
Messagechannel: str, sender_peer_id: str, data: Any, sender_metadata: dict \| None
Directsender_peer_id: str, data: Any, sender_metadata: dict \| None
Presencechannel: str, joined: tuple[PresencePeer, ...], left: tuple[PresencePeer, ...]
ServerErrorcode: ErrorCode, message: str \| None, request_id: str \| None
GoingAwayretry_after_ms: int
TokenProviderErrorconsecutive_failures: int, err: Exception

PresencePeer (peer_id: str, metadata: dict | None) and IceServerConfig (urls: str | list[str], username: str | None, credential: str | None) are exported from the package top level.

Peer / WebRTC-layer events

Emitted by MeteredPeer and RemotePeer. Listed here for completeness:

EventFieldsNotes
Joinedpeer_id: str, channel: strLocal peer entered a channel.
Leftpeer_id: str \| None, channel: str \| None, reason: str \| NoneLocal peer fully disconnected (terminal).
PeerJoinedpeer: RemotePeerAnother peer subscribed to your channel.
PeerLeftpeer: RemotePeerAnother peer left / dropped.
Datasender_peer_id: str, data: Any, kind: "broadcast" \| "direct", sender_metadata: dict \| NoneCustomer payload from another peer; kind distinguishes broadcasts from direct messages.
FatalErrorerr: ExceptionUnrecoverable condition you must react to (auth rejected, account suspended, admin disconnect, token provider stuck failing). The terminal close codes surface here on the peer layer.
Tracktrack: Any, streams: tuple[MediaStream, ...], metadata: StreamMetadata \| NoneRemote peer added a media track.
StreamAddedstream: MediaStream, metadata: StreamMetadata \| NoneFirst time a stream id appears on this peer.
StreamRemovedstream: MediaStreamEvery track of a previously-seen stream has ended.
DataChannelOpenedchannel: AnyRemote peer opened a data channel toward you.
NegotiationErrorerr: ExceptionOffer/answer negotiation failed for this peer.
IceCandidateErrorerr: ExceptionAn inbound ICE candidate was rejected (usually benign).
DCOpen / DCClose / DCError / DCMessage(DCError/DCMessage carry err / data)DataChannel wrapper lifecycle + payloads.

On the SignallingClient layer the terminal close codes arrive as a Disconnected event with will_reconnect=False. On the MeteredPeer layer those same conditions surface as a FatalError event carrying the cause.

Quick "what should I do?" cheat sheet

SymptomDiagnose withFix
connect() raises SignallingConnectError with close_code == 4001Check JWT signature, kid header, signing secretMint a new JWT with the right key
connect() raises with close_code == 4010Concurrent-connection capWait, raise plan limit, or look for connection leaks (stale clients you forgot to close())
subscribe/publish raises ServerRequestError("channel_not_authorized")Channel outside the JWT's channels claimMint a JWT that includes the channel
publish raises ServerRequestError("over_message_quota")Period quota exhausted (connection stays open)Direct user to billing, or wait for the period to roll over
publish/send raises SignallingError ("not connected")Called before await connect() resolvedMove the call behind the Connected event, or await connect() first
publish/send raises DisconnectedErrorFrame sent, connection dropped before ackRetry after reconnect if the op isn't idempotent
send() raises AckTimeoutErrorNo ack within 15 sTreat as in-doubt; retry if safe
peer.send() raises MeteredPeerSendError("not_joined")Called before await join(...) resolvedMove the send behind the Joined event
peer.send() raises MeteredPeerOversizedErrorPayload too bigChunk in application, or move bulk data to a P2P DataChannel
state stuck in "reconnecting"IndefiniteSee Reconnect Best Practices — Stuck reconnect
TokenProviderError event firingYour token_provider() keeps failingInspect ev.err; if it's user-actionable (login expired), surface the prompt

See also