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¶
- Reference: Errors
- Runnable receive loop with recovery:
echo_agent.py - Payments: Payments guide