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/peer";

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/peer";

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/peer";

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/peer";

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/peer";

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.

WebSocket close codes

WsCloseCode is exported as a value enum:

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

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.

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
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