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​