Skip to main content

Errors and retries

Every failure is an RFC 9457 problem document with the media type application/problem+json. Its type is the address of the page describing it, so an error leads straight to its own documentation.

{
"type": "https://docs.co-relayer.com/errors/rate-limited",
"title": "You are sending faster than your rate class allows.",
"status": 429,
"detail": "20 RU/s sustained exceeded for account erd1….",
"instance": "req_01JB…",
"code": "RATE_LIMITED",
"retryable": true,
"resign": "SAME_BYTES",
"details": { "retryAfterMs": 1200, "scope": "account" },
"hint": "Wait for details.retryAfterMs and resend the identical bytes."
}

The members to branch on​

MemberAlwaysUse it for
codeyesThe branch. Stable machine name.
statusyesThe fallback when code is one you do not know.
retryableyesWhether sending the same request again can succeed.
resignrelay pathWhether a signature is needed: NONE, SAME_BYTES, NEW_SIGNATURE_SAME_NONCE.
hintusuallyOne sentence naming the next action, written for a program.
instanceyesThe request id. Quote it to support.
detailssome codesMachine-readable specifics, listed on each code's page.
intentrelay pathsender, nonce, state, and txHash once one exists.

Never branch on title or detail. Both are prose meant for a human reading a log.

The enum is open​

New codes are added without a breaking change. An unknown code is handled by its status and retryable, never by failing:

function classify(problem: Problem): 'retry' | 'buy' | 'stop' {
if (problem.code === 'QUOTA_EXHAUSTED' || problem.code === 'NO_ENTITLEMENT') return 'buy';
if (problem.retryable) return 'retry';
if (problem.status >= 500) return 'retry'; // unknown 5xx: assume transient
return 'stop';
}

Additions are announced in the changelog.

The decision tree​

Retry, with the right backoff​

retry.ts
/**
* Retrying a CoRelayer call the way the problem documents say to.
*
* - retry only what the server marked `retryable`, and only by sending the same request again —
* a retry never rebuilds and never re-signs;
* - the server's `details.retryAfterMs` (or `Retry-After`) wins over any schedule of yours;
* - otherwise back off exponentially, with jitter, so a thousand agents do not retry in step.
*
* `QUOTA_EXHAUSTED` is `retryable: false` on purpose: waiting does not buy you Relay Units.
*/
import { isApiError } from '@corelayer/sdk';

export interface RetryOptions {
readonly attempts?: number;
readonly baseMs?: number;
readonly capMs?: number;
readonly sleep?: (ms: number) => Promise<void>;
readonly random?: () => number;
}

export async function withRetry<T>(run: () => Promise<T>, options: RetryOptions = {}): Promise<T> {
const attempts = options.attempts ?? 4;
const base = options.baseMs ?? 250;
const cap = options.capMs ?? 8_000;
const sleep =
options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
const random = options.random ?? Math.random;

for (let attempt = 0; ; attempt += 1) {
try {
return await run();
} catch (error) {
if (!isApiError(error) || !error.retryable || attempt + 1 >= attempts) throw error;
const serverSays = error.retryAfterMs;
const backoff = Math.min(base * 2 ** attempt, cap) * (0.5 + random());
await sleep(serverSays ?? backoff);
}
}
}

Rules that matter more than the schedule:

  • details.retryAfterMs wins. When it is present it is not advisory.
  • Jitter. Every agent retrying on the same grid is a second outage.
  • Retry means the same bytes. It never means rebuild, and it never means re-sign.

The codes by what you do about them​

Wait and resend​

CodeStatusNote
RATE_LIMITED429details.retryAfterMs. details.scope says whether it was your account or the platform.
GAS_BUDGET_EXCEEDED429The per-shard gas budget of your rate class.
HOURLY_BURN_EXCEEDED429Units per hour. A sustained pattern means: change tier.
TOO_MANY_IN_FLIGHT429Too many unsettled intents for this sender. Wait for some to finish.
NONCE_IN_FLIGHT409Another intent holds this nonce. Do not build a new one for it.
NO_RELAYER_AVAILABLE503No healthy relayer in that shard right now.
SIGNER_UNAVAILABLE · SIGNER_FENCED503Our signer. Nothing was co-signed.
UPSTREAM_UNAVAILABLE503A gateway or node, before anything was committed.
LEASE_EXPIRED409Renew the lease and resubmit the same bytes. The SDK's relay helper does this for you when the client has a native-auth token for the sender, or when you give it a proof signer so it can sign a fresh presence proof for the renewal. With a proof you signed yourself, it returns this error.

Buy something​

CodeStatusWhat to do
NO_ENTITLEMENT402No plan. The response carries a PAYMENT-REQUIRED header pointing at the x402 top-up endpoint.
QUOTA_EXHAUSTED429The units are used up: details.reason is CAP_REACHED_PAYG_OFF or PAYG_ESCROW_EMPTY. No Retry-After — waiting does not help. details.pricingUrl, details.x402Url, details.periodEndMs.
INSUFFICIENT_CREDITS402A purchase from existing credits, and there are not enough. Deposit first, or buy with depositAndSubscribe in one call.

Stop — this nonce is finished​

CodeStatus
NONCE_TOO_LOW409
INTENT_ALREADY_EXECUTED409

The slot is gone. Read your account nonce again and start over with a new one. Retrying the same nonce is an infinite loop.

import { endsSlot, isApiError } from '@corelayer/sdk';

if (isApiError(error) && endsSlot(error)) {
myNonce = await readAccountNonce(); // not a retry: a restart
}

Sign once more — and only here​

CodeStatusresign
RESIGN_REQUIRED409NEW_SIGNATURE_SAME_NONCE
RESIGN_SAME_NONCE409NEW_SIGNATURE_SAME_NONCE

Both mean: nothing was co-signed, so nothing was sent, and the transaction you already signed is inert forever. A fresh assignment comes with the error, pinned to the same nonce.

const outcome = await sendToken(context, { receiver, amount, intentKey });

if (outcome.kind === 'resign-required') {
// outcome.previousRelayer – the one that went away
// outcome.nextAssignment – the replacement, its lease pinned to the nonce
// outcome.pinnedNonce – the nonce to keep
if (await decide(outcome)) await resignWith(context, outcome, intentKey);
}

Sign with the pinned assignment from the answer — never with a fresh, unpinned one, and never with a cancel lease, which the signer fixes to a 0-value transfer to yourself.

Never infer this from a status code. Only resign says it. (The re-sign case)

Fix the request​

MALFORMED_REQUEST, GAS_LIMIT_TOO_LOW, GAS_LIMIT_TOO_HIGH, GAS_PRICE_OUT_OF_RANGE, DATA_TOO_LARGE, GAS_OVERPROVISIONED, SIMULATION_FAILED, UNSUPPORTED_TX_FIELD, VARIANTS_NOT_SUPPORTED, TX_VERSION_UNSUPPORTED. These do not change with time. Fix what you built and send a new transaction. Each one has a page saying exactly what to change.

Timeouts are not errors​

A timeout or a dropped connection from POST /v1/relay tells you nothing about whether the transaction was sent. There are exactly two correct responses:

// Either: send the identical bytes again. Idempotent on (sender, nonce, bytes).
await client.relay({ tx: signed, lease }, { intentKey });

// Or: ask.
const intent = await client.getIntent(sender, nonce);

Never rebuild. Never re-sign. Never move to the next nonce "just in case" — that is the one move that creates two live transactions for one intention, and it is exactly what the logical idempotency key exists to catch:

Idempotency-Key: order-7f2c0a41

Same key with different bytes or a different nonce, while the first intent is alive → INTENT_ALREADY_SUBMITTED, carrying the first intent so you can look at it instead of guessing.

The whole catalogue​

91 codes, one page each, at the URL their type names: /errors, and machine-readable at /errors.json.

A code that exists in the API without a page fails this site's build, so the type member always resolves.