Errors & Codes
Every exception the SDK throws, every WebSocket close code, every server-emitted error code — with what to do about each.
Exception classes
All implement Dart's Exception. Branch with on <Type> catch (e), and on the typed code field (an enum) where present.
The SDK treats these as part of its stable surface:
- Exception class names (
SignallingConnectError,MeteredPeerSendError, …) - Enum
codevalues (SendErrorCode.notJoined,StateErrorCode.invalidState,ErrorCode.overMessageQuota, …) - Field names (
e.size,e.cap,e.succeeded,e.closeCode, …)
It does not guarantee e.toString() / message text — that's human-readable and may change between versions. Never if (e.toString().contains(...)) to branch on a typed exception; use on <Type> + the enum code. (The one exception is MeteredPeer.onError, which is a generic Object — see below.)
SignallingConnectError
Thrown by client.connect() (and surfaced through peer.join()) when the WebSocket closes before the server welcome — the handshake itself failed.
try {
await client.connect();
} on SignallingConnectError catch (e) {
print('close code: ${e.closeCode}'); // int
print('reason: ${e.closeReason}'); // String — server-controlled; sanitize before display
}
Fields: closeCode (int), closeReason (String). toString() never interpolates the server reason — it's safe to log directly; read closeReason only when you specifically want the raw text.
SignallingServerError
Rejects a subscribe / publish / send when the server returns a correlated error frame. Branch on code (an ErrorCode).
try {
await client.publish(channel, data);
} on SignallingServerError catch (e) {
if (e.code == ErrorCode.overMessageQuota) showBillingUi();
// e.serverMessage — optional String? detail
}
SignallingDisconnectedError
Rejects an in-flight subscribe / publish / send when the connection closes before its ack — the frame may or may not have been processed. Fields: code (int), reason (String). Retry after reconnect if the operation isn't idempotent in your app.
MeteredPeerSendError
Thrown by peer.send / peer.sendTo / peer.join / remote.send for state + argument failures. Each code comes from specific methods — branch on code (SendErrorCode):
try {
await peer.sendTo(peerId, payload);
} on MeteredPeerSendError catch (e) {
switch (e.code) {
case SendErrorCode.notJoined: // send/sendTo before join() completed
case SendErrorCode.invalidArgs: // sendTo with an empty peerId
case SendErrorCode.selfSend: // sendTo your own peerId
case SendErrorCode.reservedChannel: // only from join(): channel uses _metered/ etc.
}
}
enum SendErrorCode { reservedChannel, notJoined, invalidArgs, selfSend }
MeteredPeerStateError
Thrown by addStream / addTrack / removeStream / removeTrack / replaceTrack / join when called in the wrong lifecycle state, or by addTrack for a track conflict / missing id.
try {
await peer.addTrack(track, stream: stream, metadata: {'role': 'camera'});
} on MeteredPeerStateError catch (e) {
switch (e.code) {
case StateErrorCode.invalidState:
// e.method: 'addStream' | 'addTrack' | 'removeStream' | 'removeTrack' | 'replaceTrack' | 'join'
// e.currentState: the state the instance was in (e.g. 'closed'), or null
if (e.currentState == 'closed') rebuildPeer();
case StateErrorCode.trackAlreadyAttached:
// same track added with a different stream — not supported yet
}
}
Fields: code (StateErrorCode), method (String), currentState (String?), plus the message.
enum StateErrorCode { invalidState, trackAlreadyAttached }
invalidState is the catch-all "wrong lifecycle state for this call" (added media after close(), joined twice, or — a Flutter-specific case — addTrack on a track whose id is null). trackAlreadyAttached fires only when the same track id is added with a different stream; same-stream re-add is idempotent.
MeteredPeerOversizedError
Thrown by peer.send / peer.sendTo and by addStream / addTrack (oversized metadata). 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).
try {
await peer.send(huge);
} on MeteredPeerOversizedError catch (e) {
print('payload ${e.size} bytes, server cap ${e.cap}');
}
Fields: size (int, attempted UTF-8 byte length), cap (int, server cap). For larger payloads, chunk at the application layer or use a P2P DataChannel.
MeteredPeerReplaceTrackError
Thrown by peer.replaceTrack when the swap succeeds on some peers and fails on others. Carries both lists so you can converge surgically.
try {
await peer.replaceTrack(oldCam, newCam);
} on MeteredPeerReplaceTrackError catch (e) {
// e.succeeded: List<String> peerIds already on newCam
// e.failed: List<ReplaceTrackFailure> still on oldCam
for (final f in e.failed) {
print('failed for ${f.peerId}: ${f.err}'); // f.err is scrubbed of SDP creds
}
}
ReplaceTrackFailure fields: peerId (String), err (Object).
DataChannelOverflowError
Thrown by DataChannel.send when the wrapper's queue exceeds maxQueuedSends — your producer is faster than the network. Fields: queued (int), cap (int).
DataChannel's constructor also throws ArgumentError for non-positive maxBufferedAmount / maxQueuedSends, or a bufferedAmountLowThreshold outside 1..maxBufferedAmount.
MeteredPeer.onError
The onError stream is the unified surface for fatal conditions you must act on. Unlike the typed exceptions above, it emits a generic Object — currently a StateError whose message carries the symbolic code. There is no err.name. Match on the symbolic strings (which are stable):
peer.onError.listen((err) {
final msg = err.toString();
if (msg.contains('invalid_token') || msg.contains('token_expired')) {
showLoginPrompt();
} else if (msg.contains('channel_not_authorized')) {
showAccessDenied();
} else if (msg.contains('account_suspended')) {
showBillingUi();
} else if (msg.contains('admin_disconnect')) {
showKickedUi();
} else if (msg.contains('token provider failed')) {
showAuthFlowBroken();
} else {
reportToCrashlytics(err);
}
});
Three things fire onError:
| Source | When | Symbolic string in the message |
|---|---|---|
| Terminal WS close code | 4001 / 4002 / 4003 / 4012 / 4020 | invalid_token / token_expired / channel_not_authorized / account_suspended / admin_disconnect (+ the numeric code) |
Fatal server-error frame | server rejected a request with channel_not_authorized / action_not_permitted | the server's code wire string |
tokenProvider exhaustion | your mint endpoint failed past the threshold | token provider failed N consecutive attempts |
Non-fatal server errors (rate limits, per-request rejections) do not fire onError — they reject the related Future (as SignallingServerError) and the connection stays open.
The Dart port surfaces onError as a coarse Object rather than the JS SDK's err.name-tagged errors. The symbolic strings (invalid_token, etc.) are stable, so matching on them is safe. If you need precise close-code branching, the equivalent path is to use SignallingClient directly and read onDisconnected.code against the WsCloseCode constants.
WebSocket close codes
WsCloseCode is exported as a class of int constants:
import 'package:metered_realtime/metered_realtime.dart';
client.onDisconnected.listen((e) {
if (e.code == WsCloseCode.adminDisconnect) showKickedUi();
});
| Code | Constant | Meaning | What the SDK does | What you do |
|---|---|---|---|---|
| 1001 | goingAway | Server shutting down for deploy | Reconnects on normal backoff | Nothing required — onGoingAway carries a retryAfterMs hint |
| 1006 | (no constant) | Abnormal close — network died | Reconnects | Show "reconnecting" UI |
| 1008 | policyViolation | Bad frame / invalid JSON shape | Reconnects | Client bug — check wire-format compliance |
| 1009 | messageTooBig | Payload exceeded server cap | Reconnects | Catch MeteredPeerOversizedError before send |
| 4000 | clientInactivity | SDK's own inactivity watchdog fired | Reconnects | Nothing — the SDK self-healing a stuck socket |
| 4001 | invalidToken | JWT signature / kid / format wrong | Terminal — no retry | Refresh the user's auth, mint a new token |
| 4002 | tokenExpired | JWT exp in the past | Reconnects (tokenProvider refresh) | Make sure tokenProvider() returns a fresh JWT |
| 4003 | channelNotAuthorized | Channel outside the JWT's channels claim | Terminal — no retry | Fix your channel list at mint time |
| 4010 | overConcurrentLimit | At your plan's concurrent-connection cap | Reconnects with a ≥30 s backoff floor | Raise your plan, or fix connection leaks |
| 4011 | overMessageRate | Per-connection rate limit | Reconnects (fresh bucket) | Throttle your producer |
| 4012 | accountSuspended | Customer-level kill switch (billing) | Terminal — no retry | Direct the user to billing |
| 4020 | adminDisconnect | DELETE /v1/peers/:id from the REST API | Terminal — no retry | Show "you were disconnected" |
Codes 4001 / 4003 / 4012 / 4020 are immediately terminal — the SDK doesn't even attempt a retry. 4002 is surfaced as terminal only once tokenProvider exhausts its retries (or if none is configured). For MeteredPeer, all five fire through the unified onError.
Server-emitted error codes (ErrorCode)
The ErrorCode enum is the typed code on SignallingServerError and ServerErrorEvent. Each has a .wire string (e.g. ErrorCode.overMessageQuota.wire == 'over_message_quota'), and ErrorCode.fromWire('peer_not_found') maps back.
ErrorCode | .wire | Triggered by | Customer fix |
|---|---|---|---|
malformedMessage | malformed_message | Invalid JSON / missing field | Client bug — file an SDK issue |
unknownType | unknown_type | Unrecognized message type | Same |
invalidChannel | invalid_channel | Channel name violates wire rules | Validate channel names client-side |
invalidPeerId | invalid_peer_id | peerId violates wire rules | Validate peerIds at mint time |
channelNotAuthorized | channel_not_authorized | Channel outside JWT's channels | Mint a JWT that includes the channel |
channelReserved | channel_reserved | Channel starts with _metered/ / _internal/ / _system/ | Pick a different prefix |
channelLimitExceeded | channel_limit_exceeded | Subscribing past the per-connection channel cap | Unsubscribe inactive channels, or open a second client |
peerNotFound | peer_not_found | send target isn't online | Race — usually just drop the send |
missingData | missing_data | publish / send without data | Client bug — data is required |
actionNotPermitted | action_not_permitted | Key's permissions doesn't grant this action | Mint a JWT with the action in permissions |
overMessageQuota | over_message_quota | Period quota exhausted; connection stays open | Direct to billing, or wait for the period to roll over |
over_message_quota is not a disconnect — the connection stays open, but further publish / send calls reject until your billing period rolls over.
Quick "what should I do?" cheat sheet
| Symptom | Fix |
|---|---|
connect() throws SignallingConnectError, closeCode == 4001 | Check JWT signature / kid / signing secret; mint a new token |
connect() throws SignallingConnectError, closeCode == 4010 | Concurrent-connection cap — wait, raise plan, or fix dispose() leaks |
send() throws MeteredPeerSendError(SendErrorCode.notJoined) | Move the send behind onJoined / await peer.join(...) |
send() throws MeteredPeerOversizedError | Chunk in app, or move bulk data to a P2P DataChannel |
onPeerJoined fires but onStreamAdded never does | On a pk_ key, tick Send — see the pk_ gotcha |
state stuck in reconnecting | See Reconnect Best Practices |
onTokenProviderError firing | Inspect e.error; if user-actionable (login expired), surface a prompt |
See also
- Reconnect Best Practices — the playbook for the close codes that matter
MeteredPeerandSignallingClient— which methods throw what