Skip to content

Error Model

Every failure surfaces as a typed error. There are three the SDK defines, plus one native browser error, and they partition cleanly by layer: transport, contract, and cancellation.

Thrown when the server returns a non-2xx response. Network failures (offline, DNS, CORS) also surface as HTTPError, with status: 0. No response arrived, so the thrown error’s message is kept in bodyText and carried into message — for example HTTP 0 AbortError: This operation was aborted, or HTTP 0 Error: The node refused to attest: HTTP 404 from a sealed fetch.

class HTTPError extends Error {
name = 'HTTPError';
status: number; // HTTP status (0 for network errors)
statusText: string; // status text (when status is 0: 'Network Error' or the thrown error's name)
url: string; // the request URL that failed
headers: Headers; // response headers
bodyText?: string; // raw response body if available (capped ~64KB); the thrown message when status is 0
explanation?: string; // the node's own words — the body's `error`, or the thrown message when status is 0
toJSON(): { status; statusText; url; headers; bodyText? };
}

message is HTTP <status> <statusText>: <explanation>, for logs. explanation is the same text without the status line, for a UI that shows what the node said.

Since core 0.11.0-rc.56, an admin request the node refuses answers with the status that names the refusal, where earlier releases answered almost every one with 500:

Status Meaning
400 The request is malformed. Sending it again cannot help.
401 Not authenticated. The SDK retries once via refresh first.
403 The node will not do this for this caller.
404 What the request names does not exist on this node, or has not synced yet.
409 The request contradicts current state: an expired or spent invitation, a removed member.
413 The body is over the node’s limit.
503 / 504 The node cannot serve this yet, for example with no member online to hand over a group key.

Most callers ask two things of a failure: what kind is it, and would the same request work later? classifyError answers both for anything a catch hands over: an HTTPError, an axios-style error, a browser TypeError from a fetch that never connected, or a plain Error.

import { classifyError } from '@calimero-network/mero-js';
try {
await sdk.admin.uploadBlob(file);
} catch (err) {
const { kind, retryable, message } = classifyError(err);
if (kind === 'too-large') showError('That file is over the node\'s limit.');
else if (retryable) scheduleRetry();
else showError(message); // the node's own words
}
kind Status retryable
invalid 400, other 4xx no
unauthorized 401 yes (after signing in)
forbidden 403 no
not-found 404 yes
conflict 409 no
gone 410 no
too-large 413 no
rate-limited 429 yes
unavailable 503, 504 yes
server other 5xx yes
unreachable 0, or a TypeError yes
unknown no status yes

retryable: false means the node chose the answer, so asking again gets the same one. For following an invitation, use admin.redeemInvitation, which builds on this and also settles a join that timed out but landed.

A subclass of HTTPError — terminal auth failure. Thrown when the node reports that the refresh token was reused or revoked, meaning refresh can’t recover. Because it extends HTTPError, instanceof HTTPError still matches; check it first if you want to distinguish it.

class AuthRevokedError extends HTTPError {
name = 'AuthRevokedError';
reason: string; // 'token_reuse' | 'token_revoked'
}

When this is thrown the SDK has already cleared its tokens (onAuthRevoked → clearToken). Prompt the user to sign in again.

Thrown by sdk.rpc.execute() when the WASM contract returns a JSON-RPC error.

class RpcError extends Error {
name = 'RpcError';
code: number; // JSON-RPC error code
type?: string; // optional error type from the contract
data?: unknown; // optional structured error data
}

The inherited message holds the human-readable description from the contract.

A request that exceeds timeoutMs, or one cancelled via an AbortSignal, does not surface as a native AbortError. Every high-level client routes through the HTTP client, which catches the underlying DOMException (AbortError/TimeoutError) and re-throws it as an HTTPError with status: 0 — the same shape as any other network failure. Match it as an HTTPError whose status is 0:

try {
await sdk.admin.getContexts(); // subject to the client's global timeoutMs
} catch (err) {
if (err instanceof HTTPError && err.status === 0) {
// network failure, timeout, or cancellation
}
}

status: 0 alone doesn’t distinguish a deliberate cancellation from a genuine network failure — track the AbortController you called .abort() on if you need to tell them apart. Per-call signal/timeoutMs overrides are a feature of the low-level HttpClient; the sdk.admin / sdk.auth / sdk.rpc facades use the single global timeoutMs you pass to the constructor.

import { HTTPError, AuthRevokedError, RpcError } from '@calimero-network/mero-js';
try {
const result = await sdk.rpc.execute({
contextId: 'ctx-id',
method: 'get_value',
argsJson: { key: 'foo' },
});
return result;
} catch (err) {
if (err instanceof RpcError) {
// Contract-level error — the WASM returned an error object
console.error(`Contract error ${err.code}: ${err.message}`, err.data);
} else if (err instanceof AuthRevokedError) {
// Refresh token reused/revoked — session is over
promptSignIn();
} else if (err instanceof HTTPError) {
// Transport-level error — HTTP 4xx/5xx, or status 0 for a network
// failure, timeout, or cancellation
if (err.status === 0) console.warn('Network failure, timeout, or cancelled');
else if (err.status === 403) console.error('Permission denied');
else console.error(`HTTP ${err.status}: ${err.statusText}`);
} else {
throw err; // something unexpected
}
}

The SDK handles 401 Unauthorized internally: it triggers a token refresh and transparently retries the original request (see the token lifecycle). Your code only sees a 401 HTTPError if the refresh itself fails (e.g. the refresh token is also expired). In that case, call sdk.clearToken() and prompt the user to re-authenticate. If the token was reused or revoked, you get an AuthRevokedError instead.