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
| Member | Always | Use it for |
|---|---|---|
code | yes | The branch. Stable machine name. |
status | yes | The fallback when code is one you do not know. |
retryable | yes | Whether sending the same request again can succeed. |
resign | relay path | Whether a signature is needed: NONE, SAME_BYTES, NEW_SIGNATURE_SAME_NONCE. |
hint | usually | One sentence naming the next action, written for a program. |
instance | yes | The request id. Quote it to support. |
details | some codes | Machine-readable specifics, listed on each code's page. |
intent | relay path | sender, 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
/**
* 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.retryAfterMswins. 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
| Code | Status | Note |
|---|---|---|
RATE_LIMITED | 429 | details.retryAfterMs. details.scope says whether it was your account or the platform. |
GAS_BUDGET_EXCEEDED | 429 | The per-shard gas budget of your rate class. |
HOURLY_BURN_EXCEEDED | 429 | Units per hour. A sustained pattern means: change tier. |
TOO_MANY_IN_FLIGHT | 429 | Too many unsettled intents for this sender. Wait for some to finish. |
NONCE_IN_FLIGHT | 409 | Another intent holds this nonce. Do not build a new one for it. |
NO_RELAYER_AVAILABLE | 503 | No healthy relayer in that shard right now. |
SIGNER_UNAVAILABLE · SIGNER_FENCED | 503 | Our signer. Nothing was co-signed. |
UPSTREAM_UNAVAILABLE | 503 | A gateway or node, before anything was committed. |
LEASE_EXPIRED | 409 | Renew 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
| Code | Status | What to do |
|---|---|---|
NO_ENTITLEMENT | 402 | No plan. The response carries a PAYMENT-REQUIRED header pointing at the x402 top-up endpoint. |
QUOTA_EXHAUSTED | 429 | The 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_CREDITS | 402 | A purchase from existing credits, and there are not enough. Deposit first, or buy with depositAndSubscribe in one call. |
Stop — this nonce is finished
| Code | Status |
|---|---|
NONCE_TOO_LOW | 409 |
INTENT_ALREADY_EXECUTED | 409 |
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
| Code | Status | resign |
|---|---|---|
RESIGN_REQUIRED | 409 | NEW_SIGNATURE_SAME_NONCE |
RESIGN_SAME_NONCE | 409 | NEW_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.