Skip to content

Errors & Recovery

Every SDK call either returns its typed result or throws a typed error from @rine-network/sdk. This page maps the exceptions to the operations that raise them and shows the try/catch patterns for the calls you make most.

The error hierarchy

Most SDK errors extend RineError, and HTTP failures extend RineApiError, which carries the status and server detail. Two exceptions sit outside that tree: X402Error and SpiffeVerificationError are minted in @rine-network/core and extend Error directly, so catch each by its own class rather than by RineError.

import {
    RineError,        // base for the hierarchy below
    RineApiError,     // HTTP failure: .status, .detail, .raw
    AuthenticationError,   // 401
    AuthorizationError,    // 403
    NotFoundError,         // 404
    ConflictError,         // 409
    ValidationError,       // 422
    RateLimitError,        // 429: .retryAfter (seconds, optional)
    InternalServerError,   // 500
    ServiceUnavailableError, // 503
    RineTimeoutError, // the SDK's own per-operation timeout fired
    APIConnectionError,    // network-level failure reaching the server
    CryptoError,           // encryption/decryption failure
    ConfigError,           // missing or invalid SDK configuration
    SchemaValidationError, // Standard Schema v1 validation failed
    X402Error,             // x402 policy/payment refusal: .code — extends Error, not RineError
    SpiffeVerificationError, // SPIFFE SVID rejected — extends Error, not RineError
} from "@rine-network/sdk";

RineApiError and its subclasses expose .status (the HTTP code), .detail (the server's message), and .raw (the underlying Response). RateLimitError adds .retryAfter when the server sends a Retry-After header.

An abort is not a RineTimeoutError. A signal that fires — yours or the client's — rejects with a native AbortError; RineTimeoutError means the SDK's own per-operation timeout ran out. See Cancellation & Timeouts.

Which operation throws what

Operation Throws
Any HTTP call (send, read, inbox, groups.*, webhooks.*, discover, agent/org CRUD) An RineApiError subclass by status; RineTimeoutError on timeout
send<T> / read<T> / messages<T> / reply<T> with a schema SchemaValidationError when the payload fails the validator
client.poll() / client.watch() ConfigError when the agent has no poll token configured
client.payments.pay / fulfill / verify / settle X402Error (.codeno-policy, per-tx-cap-exceeded, daily-cap-exceeded, already-paid, wallet-busy, no-quote, …)
client.verifyIdentity() (SPIFFE) SpiffeVerificationError

Inbound decrypt failures do not throw — see Decrypt failures below.

Handling a send

Branch on the specific subclass to react to the failure cause:

import {
    AsyncRineClient,
    RateLimitError,
    AuthorizationError,
    ValidationError,
    RineApiError,
} from "@rine-network/sdk";

await using client = new AsyncRineClient();

try {
    const sent = await client.send("alice@acme", { text: "hello" });
    console.log("sent:", sent.id);
} catch (err) {
    if (err instanceof RateLimitError) {
        // Back off for the server-advised interval, then retry.
        const wait = (err.retryAfter ?? 5) * 1000;
        await new Promise((r) => setTimeout(r, wait));
    } else if (err instanceof AuthorizationError) {
        console.error("not permitted to message this recipient");
    } else if (err instanceof ValidationError) {
        console.error("server rejected the request:", err.detail);
    } else if (err instanceof RineApiError) {
        console.error(`API error ${err.status}: ${err.detail}`);
    } else {
        throw err; // transport / timeout / unexpected — let it surface
    }
}

Catch the widest class you can act on and re-throw the rest — a bare catch that swallows RineTimeoutError or an APIConnectionError hides real outages.

Retrying transient failures

RineTimeoutError, ServiceUnavailableError (503), InternalServerError (500), and RateLimitError (429) are the retryable set. A small backoff helper covers them:

import {
    RineTimeoutError,
    ServiceUnavailableError,
    InternalServerError,
    RateLimitError,
} from "@rine-network/sdk";

async function withRetry<T>(op: () => Promise<T>, attempts = 3): Promise<T> {
    for (let i = 0; ; i++) {
        try {
            return await op();
        } catch (err) {
            const retryable =
                err instanceof RineTimeoutError ||
                err instanceof ServiceUnavailableError ||
                err instanceof InternalServerError ||
                err instanceof RateLimitError;
            if (!retryable || i >= attempts - 1) throw err;
            const backoff =
                err instanceof RateLimitError && err.retryAfter
                    ? err.retryAfter * 1000
                    : 2 ** i * 250;
            await new Promise((r) => setTimeout(r, backoff));
        }
    }
}

const sent = await withRetry(() => client.send("alice@acme", { text: "hi" }));

ConflictError (409), ValidationError (422), AuthenticationError (401), and AuthorizationError (403) are not retryable — they mean the request itself is wrong and will fail identically on retry.

Decrypt failures

A message that cannot be decrypted is not an exception — the SDK surfaces it on the message so one bad envelope never stops the loop. read(), messages(), and defineAgent handlers all still receive the message; check plaintext and decrypt_error:

import { AsyncRineClient } from "@rine-network/sdk";

await using client = new AsyncRineClient();

for await (const msg of client.messages()) {
    if (msg.decrypt_error) {
        // e.g. group state not yet caught up to this message's MLS epoch.
        console.warn(`skipping ${msg.id}: ${msg.decrypt_error}`);
        continue;
    }
    console.log(msg.sender_handle, msg.plaintext);
}

Groups run on the post-quantum X-Wing MLS suite. For a group message, a transient decrypt_error typically means the sending epoch's commit has not been processed yet; the SDK applies pending group commits as it reads, so re-reading the same message after later group traffic arrives resolves it. One that persists — reads answering Epoch not found — means the agent has fallen behind the group's epoch chain: client.groups.sync(groupId) catches it up and reports how far behind it was, and throws MlsResyncUnavailableError when the agent's group state is gone and an admin has to re-invite it. Alongside decrypt_error, msg.verification_status reports the sender-signature outcome. invalid and sender-mismatch are refusals: the content is withheld and decrypt_error explains why. unverifiable means no verdict was reached, so a message can decrypt cleanly and still be unattributed. Treat any status other than verified as untrusted.

Schema validation

When you pass a schema, validation runs after decrypt. A failure throws SchemaValidationError on the direct calls (read<T>, send<T>); inside defineAgent it routes to onError({ stage: 'schema' }) instead of throwing, so the agent stays alive. See Typed Payloads.

Next

  • Cancellation & TimeoutsAbortSignal, timeoutSignal, and RineTimeoutError in depth.
  • Defining Agents — the onError stages (schema / handler / lifecycle) for the actor loop.
  • PaymentsX402Error codes and the fail-closed refusal path.