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.
The SDK guarantees these as part of its SemVer-stable surface for 1.0:
- Error class names (
SignallingConnectError,MeteredPeerSendError, etc.) e.codeliteral values (e.g."not_joined","reserved_channel")- Error field names (
e.size,e.cap,e.succeeded, etc.)
The SDK does not guarantee:
e.messagetext — 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();
}
});
| Code | Constant | Meaning | What the SDK does | What you should do |
|---|---|---|---|---|
| 1001 | GoingAway | Server shutting down for deploy | Reconnects on normal backoff | Nothing required. The preceding going-away event carries a retryAfterMs hint if you want to delay manually. |
| 1006 | (no constant) | Abnormal close — network died | Reconnects | Show "reconnecting" UI |
| 1008 | PolicyViolation | Sent a binary frame, invalid JSON shape | Reconnects | Check your wire-format compliance — this is a client bug |
| 1009 | MessageTooBig | Payload exceeded server cap | Reconnects | Use MeteredPeerOversizedError to catch this before send |
| 4000 | ClientInactivity | SDK's own inactivity watchdog fired | Reconnects | Nothing — this is the SDK self-healing on a stuck WS |
| 4001 | InvalidToken | JWT signature wrong, malformed, or wrong key | Terminal — no retry | Refresh the user's auth, mint a new token |
| 4002 | TokenExpired | JWT's exp claim is 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 JWT-minting time |
| 4010 | OverConcurrentLimit | At your plan's concurrent-connection cap | Reconnects, but with ≥30 s backoff floor | Either raise your plan limit, or wait — retrying every 500 ms just hammers the server |
| 4011 | OverMessageRate | Per-connection rate limit hit | Reconnects (fresh bucket) | Throttle your producer; this is per-second abuse defense, not a billing cap |
| 4012 | AccountSuspended | Customer-level kill switch (unpaid balance, manual suspend) | Terminal — no retry | Direct the user to billing / contact support |
| 4020 | AdminDisconnect | DELETE /v1/peers/:id from the REST API | Terminal — no retry | Show "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.
| 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 | peerId violates wire-format rules | Validate peerIds 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 | Trying to subscribe to a 101st channel on one connection | 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 doesn't grant this action | Mint a JWT with the action in 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 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
| Symptom | Diagnose with | Fix |
|---|---|---|
connect() throws SignallingConnectError with code 4001 | Check JWT signature, kid header, signing secret | Mint a new JWT with the right key |
connect() throws with code 4010 | Concurrent-connection cap | Wait, raise plan limit, or look for connection leaks (stale clients you forgot to close()) |
send() throws MeteredPeerSendError("not_joined") | Called before await join(...) resolved | Move the send behind the joined event |
send() throws MeteredPeerOversizedError | Payload too big | Chunk in application, or move bulk data to a P2P DataChannel |
| Receiving no messages despite being subscribed | Subscribe ack arrived (no error) but messages never come | Check that the publisher is using a channel name your key's channels pattern allows them to publish on |
state stuck in "reconnecting" | Indefinite | See Reconnect Best Practices — Stuck reconnect |
token-provider-error event firing | Your tokenProvider() keeps failing | Inspect 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 throw what