Skip to main content

Errors & Codes

Every error class the SDK throws, every WebSocket close code, every server-emitted error code — with what to do about each.

Error classes

All errors extend Error. Use instanceof to branch.

Stable contract — branch on type, not text

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

  • Error class names (SignallingConnectError, MeteredPeerSendError, etc.)
  • e.code literal values (e.g. "not_joined", "reserved_channel")
  • Error field names (e.size, e.cap, e.succeeded, etc.)

The SDK does not guarantee:

  • e.message text — it's human-readable; format may change between minor versions.
  • Stack traces.

Use instanceof / e.code / e.name to branch. Never if (e.message.includes(...)) — that will break across upgrades.

SignallingConnectError

Thrown by client.connect() when the WebSocket closes before the server welcome arrives. Indicates the handshake itself failed.

import { SignallingConnectError, WsCloseCode } from "@metered-ca/realtime";

try {
await client.connect();
} catch (e) {
if (e instanceof SignallingConnectError) {
console.log("close code:", e.closeCode);
console.log("reason:", e.closeReason);
}
}

Fields: closeCode: number, closeReason: string.

Note: closeReason is server-controlled text. Sanitize before showing it in a UI. The fixed-format error.message is safe to log directly.

MeteredPeerSendError

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

import { MeteredPeerSendError } from "@metered-ca/realtime";

try {
await peer.send(...);
} catch (e) {
if (e instanceof MeteredPeerSendError) {
switch (e.code) {
case "not_joined": /* await peer.join(channel) first */ break;
case "reserved_channel": /* channel starts with _metered/ etc. */ break;
case "invalid_args": /* data was undefined or peerId malformed */ break;
case "self_send": /* you can't send to your own peerId */ break;
}
}
}

Codes: "reserved_channel" | "not_joined" | "invalid_args" | "self_send".

MeteredPeerStateError

Thrown by addStream, addTrack, removeStream, removeTrack, replaceTrack, and join when called in the wrong lifecycle state, or by addTrack when the same track is added with a conflicting stream.

import { MeteredPeerStateError } from "@metered-ca/realtime";

try {
peer.addTrack(track, stream, { role: "camera" });
} catch (e) {
if (e instanceof MeteredPeerStateError) {
switch (e.code) {
case "invalid_state":
// e.method tells you which call failed: "addStream" | "addTrack" |
// "removeStream" | "removeTrack" | "replaceTrack" | "join"
// e.currentState is the state the instance was in (e.g. "closed")
if (e.currentState === "closed") rebuildPeer();
break;
case "track_already_attached":
// Track is already attached to a different stream.
// Multi-stream membership lands in v1.1; for now, pick one
// stream association per track.
break;
}
}
}

Fields: code: "invalid_state" | "track_already_attached", method: string, currentState?: string.

"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.currentState gives the state at the time of the call.

"track_already_attached" only fires from addTrack (and the per-track loop inside addStream) when the same MediaStreamTrack reference is added with a different MediaStream argument. The same-stream re-add is idempotent; only the different-stream collision throws.

MeteredPeerOversizedError

Thrown by peer.send(...), peer.sendTo(...), and by peer.addStream(...) / peer.addTrack(...) when the metadata bag is too large. The size check runs synchronously before the wire send / before insertion into the tracking map (so over-cap metadata can't loop through reconcile re-sends).

import { MeteredPeerOversizedError } from "@metered-ca/realtime";

try {
await peer.send(huge);
} catch (e) {
if (e instanceof MeteredPeerOversizedError) {
console.log(`payload ${e.size} bytes, server cap ${e.cap}`);
}
}

Fields: size: number (attempted UTF-8 byte length), cap: number (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 Data Channels & Low Latency).

MeteredPeerReplaceTrackError

Thrown by peer.replaceTrack(oldTrack, newTrack) when the swap succeeds on some peers but fails on others. Carries both lists so you can converge surgically.

import { MeteredPeerReplaceTrackError } from "@metered-ca/realtime";

try {
await peer.replaceTrack(oldCam, newCam);
} catch (e) {
if (e instanceof MeteredPeerReplaceTrackError) {
// e.succeeded: readonly string[] of peerIds already on newCam
// e.failed: readonly { peerId, err }[] still on oldCam
for (const { peerId, err } of e.failed) {
console.warn(`failed for ${peerId}:`, err.message);
}
}
}

DataChannelOverflowError

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

Fields: queued: number, cap: number.

Name-based errors (err.name)

Some SDK errors are plain Error objects with a stable err.name instead of a dedicated class — typically because they surface through events or generic promise rejections. The names below are part of the SemVer-stable contract (the message text is not):

err.nameWhere you see itMeaningWhat to do
NotConnectedErrorsubscribe / publish / send / sendTo rejectionThe frame never left the client — you called while the connection was down.Wait for the connected event (or MeteredPeer state "joined") and retry.
DisconnectedErrorsubscribe / publish / send / sendTo rejectionThe frame was sent, but the connection dropped before the server confirmed it. The server may or may not have processed it.Retry after reconnect if the operation isn't idempotent in your app.
IceRestartExhaustedErrorRemotePeer negotiation-error eventThe automatic recovery budget for this one peer connection is spent — it won't recover on its own. All other negotiation-errors are transient.Wait for the next signalling-level reconnect (which replaces the connection), or close() + re-join.
ReconcileTimeoutErrorMeteredPeer error eventAfter a reconnect, the channel's roster couldn't be restored even after the SDK re-requested it.close() + re-join with a fresh MeteredPeer. See Reconnect Best Practices.
TokenProviderErrorMeteredPeer error eventYour tokenProvider() failed 3+ consecutive times.Surface a "please log in again" prompt.
"invalid_token", "token_expired", "channel_not_authorized", "account_suspended", "admin_disconnect"MeteredPeer error eventTerminal close codes, forwarded — see the close-code table below.Per-code action below.

WebSocket close codes

WsCloseCode is exported as a value enum:

import { WsCloseCode } from "@metered-ca/realtime";

client.on("disconnected", ({ code }) => {
if (code === WsCloseCode.AdminDisconnect) {
showKickedUI();
}
});
CodeConstantMeaningWhat the SDK doesWhat you should do
1001GoingAwayServer shutting down for deployReconnects on normal backoffNothing required. The preceding going-away event carries a retryAfterMs hint if you want to delay manually.
1006(no constant)Abnormal close — network diedReconnectsShow "reconnecting" UI
1008PolicyViolationSent a binary frame, invalid JSON shapeReconnectsCheck your wire-format compliance — this is a client bug
1009MessageTooBigPayload exceeded server capReconnectsUse MeteredPeerOversizedError to catch this before send
4000ClientInactivitySDK's own inactivity watchdog firedReconnectsNothing — this is the SDK self-healing on a stuck WS
4001InvalidTokenJWT signature wrong, malformed, or wrong keyTerminal — no retryRefresh the user's auth, mint a new token
4002TokenExpiredJWT's exp claim is 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 JWT-minting time
4010OverConcurrentLimitAt your plan's concurrent-connection capReconnects, but with ≥30 s backoff floorEither raise your plan limit, or wait — retrying every 500 ms just hammers the server
4011OverMessageRatePer-connection rate limit hitReconnects (fresh bucket)Throttle your producer; this is per-second abuse defense, not a billing cap
4012AccountSuspendedCustomer-level kill switch (unpaid balance, manual suspend)Terminal — no retryDirect the user to billing / contact support
4020AdminDisconnectDELETE /v1/peers/:id from the REST APITerminal — no retryShow "you were disconnected" + login button

Codes 4001 / 4003 / 4012 / 4020 are immediately terminal — the SDK doesn't even attempt a retry. Code 4002 (TokenExpired) is also surfaced as terminal once tokenProvider exhausts its retries (or if no tokenProvider is configured) — the SDK retries with a fresh token first; only if the refresh keeps failing does 4002 become terminal. For MeteredPeer customers, all five codes fire through the unified error event with err.name set to the symbolic code ("invalid_token" / "token_expired" / "channel_not_authorized" / "account_suspended" / "admin_disconnect").

Server-emitted error codes

Fire on the SignallingClient.server-error event — see the SignallingClient reference. Each is tied to a specific requestId — match against the request you made.

client.on("server-error", ({ code, requestId, message }) => {
console.log(code, "for", requestId);
});

MeteredPeer.error is a separate event with a different shape ({ err: Error }) — it surfaces unrecoverable internal SDK errors, not server-rejected requests. Don't confuse the two.

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_idpeerId violates wire-format rulesValidate peerIds 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_exceededTrying to subscribe to a 101st channel on one connectionOpen 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 doesn't grant this actionMint a JWT with the action in 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 reject with this error until your billing period rolls over. Quota limits don't kill the connection; they only block message-emitting actions.

Forward compatibility (SDK ≥ 1.1): the table lists the codes known to this SDK version, but code is an open string — a newer server can emit codes that aren't in the union yet, and they flow through as-is (a correlated request rejects immediately with the real code instead of timing out). Branch with equality checks (code === "..."), not exhaustive switches; if you do switch, keep a default branch.

Quick "what should I do?" cheat sheet

SymptomDiagnose withFix
connect() throws SignallingConnectError with code 4001Check JWT signature, kid header, signing secretMint a new JWT with the right key
connect() throws with code 4010Concurrent-connection capWait, raise plan limit, or look for connection leaks (stale clients you forgot to close())
send() throws MeteredPeerSendError("not_joined")Called before await join(...) resolvedMove the send behind the joined event
send() throws MeteredPeerOversizedErrorPayload too bigChunk in application, or move bulk data to a P2P DataChannel
negotiation-error with err.name === "IceRestartExhaustedError"One peer's connection is permanently dead (its automatic recovery budget is spent)Wait for the next reconnect to replace it, or close() + re-join
error with err.name === "ReconcileTimeoutError"Channel roster couldn't be restored after a reconnectclose() + re-join with a fresh MeteredPeer
Receiving no messages despite being subscribedSubscribe ack arrived (no error) but messages never comeCheck that the publisher is using a channel name your key's channels pattern allows them to publish on
state stuck in "reconnecting"IndefiniteSee Reconnect Best Practices — Stuck reconnect
token-provider-error event firingYour tokenProvider() keeps failingInspect err; if it's user-actionable (login expired), surface the prompt

See also