Skip to content

Errors and close codes ​

wRPC reuses HTTP status numbers as its error vocabulary, on every transport. A code that reaches a WebSocket client means the same thing it would over HTTP — and in REST mode it literally is the HTTP status.

The error shape ​

An error travels inside a callback packet:

json
{ "type": "callback", "id": "7", "error": { "message": "Not found", "code": 404 } }

An error MAY additionally carry structured details — validation failures put their issue list there:

json
{ "error": { "message": "Invalid arguments: text is required", "code": 400,
             "details": { "issues": [{ "message": "text is required", "path": ["text"] }] } } }

On the client it arrives as a rejected promise carrying a WrpcError:

js
try {
  await client.api.chat.send({ text });
} catch (error) {
  if (error.code === 429) backOff();
  else if (error.code >= 500) report(error);
}

WrpcError is Error plus a numeric code — and details, when the server attached any. There is no error class per code on purpose: the number crosses the wire, subclasses do not.

js
catch (error) {
  if (error.code === 400) showIssues(error.details?.issues);
}

To attach details from a handler, set error.details alongside code; the same exposure rule as the message applies — a 4xx's details travel, a 5xx's stay in the log unless error.expose = true.

Throwing from a handler ​

Attach code to any error and it becomes the wire code:

js
handler: async (context, { id }) => {
  const row = await db.find(id);
  if (!row) {
    const error = new Error('No such document');
    error.code = 404;
    throw error;
  }
  return row;
};

5xx messages do not travel

A 4xx is part of the protocol conversation and its message reaches the peer. A 5xx is a server internal: the message is replaced with the generic status text on the wire and the real one goes to the log. Opt out per error with error.expose = true. Stack traces never travel.

A code above 599 is reported as 500.

Codes the library itself sends ​

CodeMeaningWhere it comes from
400Malformed packet, unparseable frame, duplicate in-flight call id, invalid arguments, {type:'call'} against a subscriptiondispatcher, validators
403No session for access: 'session'; an SSE channel presented without its cookie identity; a cross-site GETcore, SSE
404No such unit, version or procedurerouter lookup
408Procedure timeout elapsed, or an ask went unansweredrouter, expectAnswer
409Unknown SSE channel — the server no longer holds that idSSE
429maxCalls in flight on this connection, or maxChannelsPerAddress for SSEdispatcher, SSE
499Cancelled by the caller — {type:'cancel'}, or an aborted signaldispatcher, client
500Handler threw without a code; a subscription handler returned a non-iterable; an output validator rejected the resultrouter
501No responder registered for an ask; a binary stream attempted over SSEclient, SSE
503Queue full, server draining, transport closed under an in-flight call, maxChannels reachedrouter, core, SSE
400, 409, 426 (sealed)A sealed request refused before it was read: not accepted (the pinned key may be retired), stale or replayed (a clock), or plaintext where encryption is required. Bare statuses, no body — the calls the request carried fail with the same code on the client, and the client emits EncryptionRefusedError (code: 'ENCRYPTION_REFUSED', status); any other plaintext answer to a sealed request is a 503encryption

499 is not a failure

It acknowledges a cancel the caller asked for. The client resolves the already-rejected call silently rather than surfacing it twice — you see the AbortError you caused, not a second error from the server.

Which codes are worth retrying ​

CodeRetry?Broker consumerWhy
400, 403, 404, 501Nodead letterThe same request will fail identically.
408CarefulretryOnly if the operation is idempotent — it may have completed.
409Automatic—The SSE transport starts a fresh channel itself.
429, 503Yes, with backoffretryLoad or a rolling deploy; both are temporary by construction.
499No—You caused it.
500No, alertretry, then dead letterA bug, not a condition — but on a queue usually a dependency that fell over.

The Broker consumer column is the default retryOn policy of queue consumers: at-least-once delivery already requires an idempotent handler, so a transient 408/500 is retried with backoff up to the attempt limit before the message is dead-lettered.

The client's own reconnect already applies truncated exponential backoff with full jitter to the connection. Per-call retries are yours: wRPC never replays a call automatically, because it cannot know whether yours is idempotent.

WebSocket close codes ​

Close codes are a different vocabulary — RFC 6455's, not HTTP's — and they end the connection, not one call. Exported as CLOSE_CODES from @alexify/wrpc/ws. A WebTransport session ends with the same codes — the server's 1001 on shutdown, 1002 on a framing violation — as its closeCode.

CodeNameWhen wRPC uses it
1000Normal closureclient.close(), a clean goodbye.
1001Going awayThe server is shutting down. Reconnect elsewhere.
1002Protocol errorA frame violated RFC 6455 — bad opcode, bad continuation, reserved bit set. With reason encryption: a session handshake frame that did not verify or parse, or a sealed frame that did not open.
1006Abnormal closureNo close frame arrived. Never sent — it is what a local socket reports.
1007Invalid payloadA text frame that was not valid UTF-8.
1008Policy violationWith reason encryption: the session handshake was refused by policy — plaintext where encryption is required, an unlisted protocol or key id, authorize said no, the handshake timed out, or the server had too much to say before it finished.
1009Message too bigOver maxPayload.
1011Internal errorThe engine could not continue.

Codes 3000–4999 are yours to use; the parser accepts them and rejects everything else outside the table above.

A client that sees 1001 reconnects with backoff, reloads its units and re-opens its subscriptions. A client that sees 1009 will do the same, and fail the same way — that one is a signal to send less, not to retry.

Released under the MIT License.