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

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?Why
400, 403, 404, 501NoThe same request will fail identically.
408CarefulOnly if the operation is idempotent — it may have completed.
409AutomaticThe SSE transport starts a fresh channel itself.
429, 503Yes, with backoffLoad or a rolling deploy; both are temporary by construction.
499NoYou caused it.
500No, alertA bug, not a condition.

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.

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.
1006Abnormal closureNo close frame arrived. Never sent — it is what a local socket reports.
1007Invalid payloadA text frame that was not valid UTF-8.
1009Message too bigOver maxPayload.
1011Internal errorThe engine could not continue.

Codes 30004999 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.