Skip to main content

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.

Stable contract — branch on type + enum, not text

The SDK treats these as part of its stable surface:

  • Exception class names (SignallingConnectError, MeteredPeerSendError, …)
  • Enum code values (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:

SourceWhenSymbolic string in the message
Terminal WS close code4001 / 4002 / 4003 / 4012 / 4020invalid_token / token_expired / channel_not_authorized / account_suspended / admin_disconnect (+ the numeric code)
Fatal server-error frameserver rejected a request with channel_not_authorized / action_not_permittedthe server's code wire string
tokenProvider exhaustionyour mint endpoint failed past the thresholdtoken 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.

Why string-matching here

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();
});
CodeConstantMeaningWhat the SDK doesWhat you do
1001goingAwayServer shutting down for deployReconnects on normal backoffNothing required — onGoingAway carries a retryAfterMs hint
1006(no constant)Abnormal close — network diedReconnectsShow "reconnecting" UI
1008policyViolationBad frame / invalid JSON shapeReconnectsClient bug — check wire-format compliance
1009messageTooBigPayload exceeded server capReconnectsCatch MeteredPeerOversizedError before send
4000clientInactivitySDK's own inactivity watchdog firedReconnectsNothing — the SDK self-healing a stuck socket
4001invalidTokenJWT signature / kid / format wrongTerminal — no retryRefresh the user's auth, mint a new token
4002tokenExpiredJWT exp in the pastReconnects (tokenProvider refresh)Make sure tokenProvider() returns a fresh JWT
4003channelNotAuthorizedChannel outside the JWT's channels claimTerminal — no retryFix your channel list at mint time
4010overConcurrentLimitAt your plan's concurrent-connection capReconnects with a ≥30 s backoff floorRaise your plan, or fix connection leaks
4011overMessageRatePer-connection rate limitReconnects (fresh bucket)Throttle your producer
4012accountSuspendedCustomer-level kill switch (billing)Terminal — no retryDirect the user to billing
4020adminDisconnectDELETE /v1/peers/:id from the REST APITerminal — no retryShow "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.wireTriggered byCustomer fix
malformedMessagemalformed_messageInvalid JSON / missing fieldClient bug — file an SDK issue
unknownTypeunknown_typeUnrecognized message typeSame
invalidChannelinvalid_channelChannel name violates wire rulesValidate channel names client-side
invalidPeerIdinvalid_peer_idpeerId violates wire rulesValidate peerIds at mint time
channelNotAuthorizedchannel_not_authorizedChannel outside JWT's channelsMint a JWT that includes the channel
channelReservedchannel_reservedChannel starts with _metered/ / _internal/ / _system/Pick a different prefix
channelLimitExceededchannel_limit_exceededSubscribing past the per-connection channel capUnsubscribe inactive channels, or open a second client
peerNotFoundpeer_not_foundsend target isn't onlineRace — usually just drop the send
missingDatamissing_datapublish / send without dataClient bug — data is required
actionNotPermittedaction_not_permittedKey's permissions doesn't grant this actionMint a JWT with the action in permissions
overMessageQuotaover_message_quotaPeriod quota exhausted; connection stays openDirect 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

SymptomFix
connect() throws SignallingConnectError, closeCode == 4001Check JWT signature / kid / signing secret; mint a new token
connect() throws SignallingConnectError, closeCode == 4010Concurrent-connection cap — wait, raise plan, or fix dispose() leaks
send() throws MeteredPeerSendError(SendErrorCode.notJoined)Move the send behind onJoined / await peer.join(...)
send() throws MeteredPeerOversizedErrorChunk in app, or move bulk data to a P2P DataChannel
onPeerJoined fires but onStreamAdded never doesOn a pk_ key, tick Send — see the pk_ gotcha
state stuck in reconnectingSee Reconnect Best Practices
onTokenProviderError firingInspect e.error; if user-actionable (login expired), surface a prompt

See also