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)
The SDK guarantees these as part of its SemVer-stable surface:
- Exception class names (
SignallingConnectError,MeteredPeerSendError, etc.) err.codeliteral 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.DisconnectedErrorspecifically 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/senddo not raise this — they don't run a client-side size check. An over-cap payload there is rejected server-side and surfaces as a1009(MessageTooBig) close. The size check is aMeteredPeer-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()
| Code | Member | Meaning | What the SDK does | What you should do |
|---|---|---|---|---|
| 1001 | GOING_AWAY | Server shutting down for deploy | Reconnects on normal backoff | Nothing 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 rejected | Reconnects | Show "reconnecting" UI |
| 1008 | POLICY_VIOLATION | Sent a binary frame, or invalid JSON shape | Reconnects | Check your wire-format compliance — this is a client bug |
| 1009 | MESSAGE_TOO_BIG | Payload exceeded the server cap | Reconnects | On the MeteredPeer layer, catch MeteredPeerOversizedError before send |
| 4000 | CLIENT_INACTIVITY | The SDK's own inactivity watchdog fired (no frames within inactivity_timeout) | Reconnects | Nothing — this is the SDK self-healing on a stuck WS. The server never emits it. |
| 4001 | INVALID_TOKEN | JWT signature wrong, malformed, or wrong key | Terminal — no retry | Refresh the user's auth, mint a new token |
| 4002 | TOKEN_EXPIRED | JWT's exp claim is in the past | Reconnects (token_provider refresh) | Make sure token_provider() returns a fresh JWT |
| 4003 | CHANNEL_NOT_AUTHORIZED | Channel outside the JWT's channels claim | Terminal — no retry | Fix your channel list at JWT-minting time |
| 4010 | OVER_CONCURRENT_LIMIT | At your plan's concurrent-connection cap | Reconnects, but with a ≥30 s backoff floor | Raise your plan limit, or wait — retrying every 500 ms just hammers the server |
| 4011 | OVER_MESSAGE_RATE | Per-connection rate limit hit | Reconnects (fresh bucket) | Throttle your producer; this is per-second abuse defense, not a billing cap |
| 4012 | ACCOUNT_SUSPENDED | Account-level kill switch | Terminal — no retry | Direct the user to billing / contact support |
| 4020 | ADMIN_DISCONNECT | Disconnected via the REST API | Terminal — no retry | Show "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 asDisconnected.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; matchrequest_idif you need to. - On the
ServerErrorevent — 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)
| Code | Triggered by | Customer fix |
|---|---|---|
malformed_message | Invalid JSON, missing required field | Client bug — file an SDK issue if you hit this |
unknown_type | Unrecognized message type | Same |
invalid_channel | Channel name violates the wire-format rules (length, charset) | Validate channel names client-side before subscribe |
invalid_peer_id | peer_id violates wire-format rules | Validate peer ids at JWT-mint time |
channel_not_authorized | Channel outside JWT's channels claim | Mint a JWT that includes the channel |
channel_reserved | Channel starts with _metered/, _internal/, _system/ | Pick a different prefix |
channel_limit_exceeded | Subscribing past the per-connection channel cap | Open a second SignallingClient or unsubscribe from inactive channels |
peer_not_found | send target isn't online on this app | Race condition (peer left between your decision and your send) — typically just drop the send |
missing_data | publish / send without data | Client bug — data is required |
action_not_permitted | Key's permissions don't grant this action | Mint a JWT with the action in its permissions |
over_message_quota | Period message quota exhausted; connection stays open | Direct 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
| Event | Fields |
|---|---|
Connected | peer_id: str, server_time: int, expires_at: int \| None, is_reconnect: bool, max_message_size: int, ice_servers: tuple[IceServerConfig, ...] \| None |
Disconnected | code: int, reason: str, will_reconnect: bool |
StateChange | from_: str, to: str |
Message | channel: str, sender_peer_id: str, data: Any, sender_metadata: dict \| None |
Direct | sender_peer_id: str, data: Any, sender_metadata: dict \| None |
Presence | channel: str, joined: tuple[PresencePeer, ...], left: tuple[PresencePeer, ...] |
ServerError | code: ErrorCode, message: str \| None, request_id: str \| None |
GoingAway | retry_after_ms: int |
TokenProviderError | consecutive_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:
| Event | Fields | Notes |
|---|---|---|
Joined | peer_id: str, channel: str | Local peer entered a channel. |
Left | peer_id: str \| None, channel: str \| None, reason: str \| None | Local peer fully disconnected (terminal). |
PeerJoined | peer: RemotePeer | Another peer subscribed to your channel. |
PeerLeft | peer: RemotePeer | Another peer left / dropped. |
Data | sender_peer_id: str, data: Any, kind: "broadcast" \| "direct", sender_metadata: dict \| None | Customer payload from another peer; kind distinguishes broadcasts from direct messages. |
FatalError | err: Exception | Unrecoverable 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. |
Track | track: Any, streams: tuple[MediaStream, ...], metadata: StreamMetadata \| None | Remote peer added a media track. |
StreamAdded | stream: MediaStream, metadata: StreamMetadata \| None | First time a stream id appears on this peer. |
StreamRemoved | stream: MediaStream | Every track of a previously-seen stream has ended. |
DataChannelOpened | channel: Any | Remote peer opened a data channel toward you. |
NegotiationError | err: Exception | Offer/answer negotiation failed for this peer. |
IceCandidateError | err: Exception | An inbound ICE candidate was rejected (usually benign). |
DCOpen / DCClose / DCError / DCMessage | (DCError/DCMessage carry err / data) | DataChannel wrapper lifecycle + payloads. |
On the
SignallingClientlayer the terminal close codes arrive as aDisconnectedevent withwill_reconnect=False. On theMeteredPeerlayer those same conditions surface as aFatalErrorevent carrying the cause.
Quick "what should I do?" cheat sheet
| Symptom | Diagnose with | Fix |
|---|---|---|
connect() raises SignallingConnectError with close_code == 4001 | Check JWT signature, kid header, signing secret | Mint a new JWT with the right key |
connect() raises with close_code == 4010 | Concurrent-connection cap | Wait, 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 claim | Mint 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() resolved | Move the call behind the Connected event, or await connect() first |
publish/send raises DisconnectedError | Frame sent, connection dropped before ack | Retry after reconnect if the op isn't idempotent |
send() raises AckTimeoutError | No ack within 15 s | Treat as in-doubt; retry if safe |
peer.send() raises MeteredPeerSendError("not_joined") | Called before await join(...) resolved | Move the send behind the Joined event |
peer.send() raises MeteredPeerOversizedError | Payload too big | Chunk in application, or move bulk data to a P2P DataChannel |
state stuck in "reconnecting" | Indefinite | See Reconnect Best Practices — Stuck reconnect |
TokenProviderError event firing | Your token_provider() keeps failing | Inspect ev.err; if it's user-actionable (login expired), surface the prompt |
See also
- Reconnect Best Practices — the playbook for the close codes that matter
MeteredPeerandSignallingClientfor which methods raise what