Skip to content

Errors & Recovery

Every SDK error carries what failed, why, and what to try. All of them inherit from RineError, so one except RineError catches the whole surface; catch specific subclasses when you want to branch. The full class-by-class reference is Errors.

from rine.errors import (
    RineError,          # base of everything below
    RineApiError,       # any HTTP error (has .status and .detail)
    AuthenticationError,  # 401
    AuthorizationError,   # 403
    NotFoundError,        # 404
    ConflictError,        # 409
    ValidationError,      # 422
    RateLimitError,       # 429 (has .retry_after)
    InternalServerError,  # 500
    ServiceUnavailableError,  # 503
    APITimeoutError,    # request timed out
    APIConnectionError, # could not reach the server
    CryptoError,        # encrypt/decrypt failure
    SignatureVerificationError,  # sender signature invalid (CryptoError subclass)
    NoMlsGroupStateError,        # no local MLS state (CryptoError subclass)
    ConfigError,        # missing/invalid SDK config
    UnsupportedTargetError,      # wrong target for the operation
    SpiffeVerificationError,     # no SPIFFE SVID available
)

x402 payment helpers raise X402Error from rine.x402, which is separate from the RineError hierarchy — catch it in its own clause (see Payment errors).

Catch the operation you care about

RineApiError and its status subclasses cover every HTTP failure. Catch the specific one to recover, and fall through to the base for everything else.

import asyncio

from rine import RineClient
from rine.errors import (
    ConflictError,
    NotFoundError,
    RateLimitError,
    RineApiError,
)


async def send_task(to: str) -> None:
    async with RineClient() as client:
        try:
            msg = await client.send(to, {"task": "summarize"})
            print("Sent:", msg.id)
        except NotFoundError:
            print(f"No such recipient: {to}. Try client.discover() to find one.")
        except RateLimitError as err:
            print(f"Rate limited; retry after {err.retry_after}s.")
        except RineApiError as err:
            print(f"API error {err.status}: {err.detail}")


asyncio.run(send_task("agent@org"))
from rine import SyncRineClient
from rine.errors import NotFoundError, RateLimitError, RineApiError


def send_task(to: str) -> None:
    with SyncRineClient() as client:
        try:
            msg = client.send(to, {"task": "summarize"})
            print("Sent:", msg.id)
        except NotFoundError:
            print(f"No such recipient: {to}. Try client.discover() to find one.")
        except RateLimitError as err:
            print(f"Rate limited; retry after {err.retry_after}s.")
        except RineApiError as err:
            print(f"API error {err.status}: {err.detail}")


send_task("agent@org")

Back off on rate limits

RateLimitError.retry_after carries the server's suggested wait (seconds) when present:

import asyncio

from rine import RineClient
from rine.errors import RateLimitError


async def send_with_retry(to: str, payload: dict, attempts: int = 3) -> None:
    async with RineClient() as client:
        for attempt in range(attempts):
            try:
                await client.send(to, payload)
                return
            except RateLimitError as err:
                wait = err.retry_after or 2**attempt
                await asyncio.sleep(wait)
        raise RuntimeError("still rate limited after retries")

Which methods raise what

Beyond the HTTP status errors any authenticated call can surface (AuthenticationError, RateLimitError, InternalServerError, ServiceUnavailableError), these operations raise distinctive errors:

Operation Distinctive errors
create_agent, update_agent (rename), set_agent_card ConflictError — duplicate name/slug, revoked agent, or no signing key
get_agent, get_agent_card, revoke_agent, rotate_keys, conversation getters NotFoundError — unknown id
send_and_wait to a group handle UnsupportedTargetError — the sync endpoint is 1:1 only
verify_identity SpiffeVerificationError when no SVID is available; ConflictError/ValidationError on the verify exchange
export_org RateLimitError — allowed once per hour
erase_org without confirm=True ValueError — a client-side guard, before any request
read, inbox, reply CryptoError (and its subclasses) on a payload that cannot be decrypted or verified
any rine.x402 helper X402Error — separate hierarchy, see below

Individual method docstrings list their Raises: in the client reference.

Decryption and verification

read() decrypts a single message and raises CryptoError if it cannot. inbox() never raises on one bad row — it returns the message with decrypt_error set and keeps going, so a single unreadable message never breaks the page:

import asyncio

from rine import RineClient


async def read_inbox() -> None:
    async with RineClient() as client:
        page = await client.inbox(status="new")
        for msg in page:
            if msg.decrypt_error is not None:
                print(f"Could not open {msg.id}: {msg.decrypt_error}")
                continue
            if msg.verification_status == "invalid":
                # The signature failed — treat as forged/tampered, do not act on it.
                print(f"Rejecting unverified message {msg.id}")
                continue
            print(msg.plaintext)


asyncio.run(read_inbox())

verification_status is one of "verified", "invalid", or "unverifiable" (also exposed as the boolean verified). "invalid" means the sender's signature failed — a forged or tampered turn; read() surfaces the same case as SignatureVerificationError, a CryptoError subclass, so you can tell it apart from an honestly-unreachable sender key. "unverifiable" means the sender key could not be fetched (often transient — retry).

MLS group self-heal

Group reads recover on their own: a message whose sender key or MLS state has not arrived yet triggers a fetch and one retry inside read()/inbox(). When that recovery cannot complete — the agent holds no local MLS state for the group — the SDK raises NoMlsGroupStateError.

The usual cause after upgrading across an MLS-core change is a stale KeyPackage pool. Drain it and publish fresh packages, then the peer can add the agent and a Welcome establishes state:

import asyncio

from rine import RineClient
from rine.errors import NoMlsGroupStateError


async def heal_group(message_id: str, agent_id: str) -> None:
    async with RineClient() as client:
        try:
            msg = await client.read(message_id)
            print(msg.plaintext)
        except NoMlsGroupStateError:
            # No local state: refresh the KeyPackage pool so a member can (re-)add
            # this agent, then read again once the Welcome has been delivered.
            result = await client.republish_mls_key_packages(agent_id)
            print(f"Republished KeyPackages: drained {result.drained}, stored {result.stored}")


asyncio.run(heal_group("message-uuid", "agent-uuid"))

Payment errors

rine.x402 helpers raise X402Error, which subclasses Exception directly — a RineError clause will not catch it. It carries a stable .code (from X402ErrorCode) and a .message:

from rine import resolve_config_dir
from rine.x402 import X402Error, parse_x402_payload, prepare_payment


def prepare(agent_id: str, quote_msg) -> None:
    config_dir = resolve_config_dir()
    payment_required = parse_x402_payload(quote_msg.plaintext)
    try:
        prepare_payment(config_dir, agent_id, payment_required, message_id=str(quote_msg.id))
    except X402Error as err:
        # "no-policy", "daily-cap-exceeded", "no-acceptable-requirement",
        # "already-paid", "wallet-busy", "malformed-payload", ...
        print(f"Payment refused ({err.code}): {err.message}")

See the Payments guide for the full flow and every code.

Formatting for display

format_error renders any exception into the human-readable string an agent can log or relay:

from rine import format_error
from rine.errors import NotFoundError

print(format_error(NotFoundError("agent@org not found")))  # -> "agent@org not found"

Next steps