Error handling
The SDK throws ObscuraError with a typed code for every failure mode.
Switch on err.code for user-facing responses, or rethrow unknown codes.
The pattern
agent.ts·ts
import { Obscura, ObscuraError } from "@obscura-app/sdk";
const agent = new Obscura({
apiKey: process.env.OBSCURA_KEY!,
baseUrl: process.env.OBSCURA_BASE_URL!,
});
try {
const res = await agent.fetch(url);
return await res.json();
} catch (err) {
if (err instanceof ObscuraError) {
switch (err.code) {
case "over_cap":
// Monthly spend cap hit. Raise it in the dashboard or wait
// for the calendar-month reset.
throw new Error("This agent has no budget left this month.");
case "insufficient_funds":
// Encrypted balance is below the requested amount. Top up.
throw new Error("Agent needs a top-up before retrying.");
case "agent_inactive":
// Agent paused or cancelled in the dashboard.
throw new Error("Agent is inactive.");
case "invalid_token":
// API key revoked, rotated, or mistyped.
throw new Error("Obscura API key invalid — rotate in dashboard.");
case "invalid_challenge":
// The merchant sent a malformed 402 — not your fault.
throw new Error("Merchant payment request was invalid.");
case "conflict":
// The same spend intent is already in flight. Don't retry.
throw new Error("Duplicate spend already processing — wait, don't retry.");
case "signing_failed":
// Umbra didn't finalize. May be in flight on-chain — DON'T retry
// until reconciliation has run, or you risk double-charging.
throw new Error("Payment is in flight — check status before retrying.");
case "network_error":
case "timeout":
// Couldn't reach Obscura, or the sign request exceeded the timeout.
throw new Error("Obscura unreachable — retry in a moment.");
default:
throw err;
}
}
throw err;
}Every error code
| Code | Status | When it fires |
|---|---|---|
over_cap | 402 | Agent's monthly spend cap would be exceeded. Terminal — raise cap or wait for month reset. |
insufficient_funds | 402 | Agent's encrypted balance is below the requested amount. Terminal — top up before retrying. |
agent_inactive | 403 | Agent is paused or cancelled. Terminal. |
missing_token | 401 | No Bearer token on the request to Obscura. The SDK sets this for you — only fires if apiKey is empty. |
invalid_token | 401 | API key is unknown, revoked, or mis-formatted. Terminal. |
invalid_challenge | 400 | Merchant's 402 response is malformed or for the wrong network/mint. Terminal. |
conflict | 409 | An identical spend intent (same agent + URL + amount + payTo + asset) is already in flight. Terminal — let the original finish. |
rate_limited | 429 | Agent has 3+ parallel spends in flight. Retry after a backoff. |
signing_failed | 500 | Umbra MPC didn't finalize within timeout. The on-chain debit may still land — DO NOT retry without checking reconciliation. |
bad_request | 400 | SDK-level constructor issue (e.g. missing apiKey) or a malformed sign-request body. |
server_error | 500 | Unhandled Obscura backend error. Retried automatically by the SDK with exponential backoff. |
no_payment_required_header | — | Merchant returned 402 but without a PAYMENT-REQUIRED header (protocol violation on the merchant). |
network_error | — | Couldn't reach Obscura. Check baseUrl / connectivity. Retried automatically. |
timeout | — | Sign request exceeded signTimeoutMs (default 60s). The mixer transfer may still complete — verify before retrying. |
unknown | — | Catch-all for unrecognized error codes. Treat as a retryable server-side issue. |
Built-in retries
The SDK retries transient failures automatically — network_error,
timeout, server_error, rate_limited — with exponential backoff
(500ms → 1s → 2s, capped at 8s, default 2 retries). Tune via
signMaxRetries and signRetryBaseMs.
Terminal codes are never retried by the SDK, no matter how high you
set signMaxRetries:
| Code | Why never retried |
|---|---|
conflict | Original is in flight — duplicating it just blocks on the same lock. |
over_cap | Cap won't change between retries. |
insufficient_funds | Balance won't change without a top-up. |
agent_inactive | Status flag — won't flip without dashboard action. |
invalid_token | Auth won't fix itself. |
missing_token | Same — auth misconfigured. |
invalid_challenge | Merchant payload is malformed. |
no_payment_required_header | Merchant violated the x402 spec. |
bad_request | Caller-side error. |
signing_failed is a special case — it's NOT retried by the SDK because
the on-chain debit may have already landed, and a blind retry would
double-charge. Surface it to your caller and let them decide after
reconciliation completes.