Skip to Content
Symmio Trading-SDK — the SDK surface for builders on Arbitrum
CoreGaslessErrors & fallback

Errors & fallback

How gasless failures surface, how to read the vendor’s diagnostics out of them, when a wallet-paid retry is safe, and what to do when a submit’s outcome is simply unknown.

The fallback safety rule

A wallet-paid retry of the same intent is safe only when the relay definitively refused it before any broadcast: a fee/quota rejection, or the gateway dropping the request before any service saw it (429, or a 503 carrying the gateway’s own envelope). Never on a timeout, a 502/504, a status 0, an idempotency conflict, or anything after a 202 — the relayed transaction may still execute, and a wallet duplicate would execute the same intent twice. Nor on a 429 that answers the resend of one of those ambiguous failures: it speaks for the resend, not for the attempt before it.

Two predicates encode exactly this — use them instead of re-deriving the rule:

import { isConfirmedGaslessFeeLimitError, isConfirmedGaslessUnavailableError } from "@symmio/trading-core"; try { await relayInstantOperations(config, parameters); } catch (err) { const safeToUseWallet = isConfirmedGaslessFeeLimitError(err) || (!accepted && isConfirmedGaslessUnavailableError(err)); if (!safeToUseWallet) throw err; // …fall back to the gas-paid path }

Both work on the error a React hook throws too: SymmioRequestError keeps the status and the response body, and every gasless predicate reads them structurally rather than through an instanceof check.

The transparent execution mode applies them for you under fallback: "wallet", with the same exclusions hard-coded: an unconfirmed submit, an idempotency conflict and a 2xx from the wrong protocol instance end the write, whatever fallback says.

One more rejection is safe to retry through the wallet, because it happens before anything is signed: the fee pre-flight finding the daily free quota exhausted (GASLESS_FREE_QUOTA_EXHAUSTED, matched by isGaslessFreeQuotaExhaustedError). The transparent mode falls back on it too. A fee quote that fails for transport reasons is not a rejection: the transparent mode signs and relays anyway, and the relayer’s simulation decides.

The matrix

OutcomeSDK behaviorWallet fallback
429, or 503 with the gateway { error } envelopeResent under the same key, up to twice, then thrownYes
Fee code (FEE_POLICY_WOULD_REVERT / INSUFFICIENT_ALLOWANCE) on any 4xxThrown as-isYes
400 SIMULATION_REVERTED with a billing revert (OperationalFee: …, DailyFreeOpsLimitExceeded, FeeLimitExceeded)Thrown as-isYes
A stored record that ended rejected for a billing reasonThrown by the dispatcher / confirmGaslessRequestYes
Status 0, a timeout, 500, 502, 504, 503 without the envelopeResent once after a backoff, then GASLESS_SUBMIT_UNCONFIRMEDNo — resubmit
Any failure of that resend — a 429, a gateway 503 or a fee code includedGASLESS_SUBMIT_UNCONFIRMED, the answer kept as causeNo — resubmit
A 2xx with no request_idGASLESS_SUBMIT_UNCONFIRMEDNo — resubmit
A 2xx from another protocol instanceGASLESS_INSTANCE_MISMATCH, body kept in responseDataNo — reconcile
409 IDEMPOTENCY_KEY_CONFLICTThrown as-isNo — new key
404 with the gateway envelopeThrown as-isNo — config
401 / 403 / 422, any other 4xxThrown as-isNo
Anything after a 202 that is not a billing rejectedGASLESS_RELAY_REJECTED / _FAILED / _REVERTEDNo

502 and 504 look like outages and are not treated as one on purpose. The gateway returns 502 for an upstream failure and for a protocol-instance identity mismatch, and either can happen after the body reached the service — which is exactly the shape that produced a double execution when the SDK treated them as “definitely not accepted”.

An ambiguous failure also decides every answer after it. Whatever the SDK’s resend is told — a 429, a gateway 503, even a fee code — describes the resend only, while the first attempt may be executing. So the submit ends as GASLESS_SUBMIT_UNCONFIRMED, never as a rejection a wallet-paid retry may follow.

When the outcome is unknown

A submit that fails ambiguously is resent once by the SDK under the same idempotency key, after a jittered 1–2 s backoff: a resend that races the first attempt’s insert can be answered with a 5xx instead of the record that insert created. If the resend does not land either, however it is answered, the SDK throws GASLESS_SUBMIT_UNCONFIRMED carrying the exact bytes that were sent:

import { getGaslessUnconfirmedSubmit, resubmitGaslessRequest } from "@symmio/trading-core"; const pending = getGaslessUnconfirmedSubmit(err); if (pending) { await store.save(pending); // { chainId, service, path, body, idempotencyKey } await resubmitGaslessRequest(config, pending); }

Resending the identical request under the same key either lands it or returns the record the service already created, so it can never double-execute. See resubmitGaslessRequest — and keep the record out of third-party logging, because body holds the user’s signed operations.

Idempotency keys

One key describes one payload. Reuse it only to replay the byte-identical request; change anything — re-sign, add an operation, move a wallet id, settle again after a terminal status — and the key must change too, or the service answers 409 IDEMPOTENCY_KEY_CONFLICT (isGaslessIdempotencyConflictError).

That is why only the actions whose payload you build take an idempotencyKey:

ActionidempotencyKey
relayInstantOperationsYours — you signed the operations
settleGaslessDepositNewAccountYours — the payload is data, not a signature
settleGaslessDepositExistingAccountYours
gaslessWalletExecuteMinted per call — it signs a fresh operation each time
relayGrantDelegationMinted per call
Transparent writes (gasless: true)Minted per call

Every acceptance receipt echoes the key that was used, so a relay you did not key yourself is still reconcilable — persist requestId, owner, walletIds and idempotencyKey together.

Reading the vendor detail

HTTP failures are thrown as SymmApiError with the raw response body preserved; parseGaslessErrorDetail digs the vendor’s { code, message, details } envelope out of any gasless error — including whatever the service decoded of an on-chain revert:

import { parseGaslessErrorDetail } from "@symmio/trading-core"; const detail = parseGaslessErrorDetail(err); detail?.code; // "SIMULATION_REVERTED", "INSUFFICIENT_ALLOWANCE", … detail?.contractRevert?.error; // "InvalidWalletOperationTarget", … detail?.revertMessage; // "OperationalFee: Insufficient balance" detail?.decodedRevert; // the service's own decoded view, in whatever shape it sent detail?.rpcError; // the nested JSON-RPC error, when the failure came from the chain detail?.failedOperation; // { index, revertData } of an OperationFailed revert detail?.gatewayError; // "Unknown or disabled protocol instance: …" (the gateway's own { error } body) detail?.validationErrors; // [{ loc: ["path", "request_id"], msg, type }] (a 422)

revertMessage, decodedRevert, rpcError and failedOperation are read from both detail and detail.details, because deployments differ on which level they use.

contractRevert.arguments is readonly unknown[] | Record<string, unknown>: the vendor sends a positional array or an object keyed by parameter name depending on how it decoded the revert. Narrow with Array.isArray before indexing, or read contractRevert.decoded, which is a plain string on the deployments that key their arguments.

Naming the failing batch entry

A relayed batch is atomic, so the InstantLayer reports an inner failure as OperationFailed(operationIndex, revertData) — a position, not a cause. Decode it against the ABI the failing operation targeted:

import { accountLayerAbi, decodeGaslessOperationFailure } from "@symmio/trading-core"; const failure = decodeGaslessOperationFailure(err, accountLayerAbi); failure?.index; // 1 failure?.decoded?.errorName; // "InvalidNameLength"

Classifying a failure

classifyGaslessFailure reduces any gasless failure to the one reason a UI should act on, reading the decoded revert ahead of the vendor code and the vendor code ahead of the HTTP status — a 400 SIMULATION_REVERTED whose revert names an allowance shortfall is an allowance problem, not a bad request:

import { classifyGaslessFailure } from "@symmio/trading-core"; switch (classifyGaslessFailure(err)) { case "payer-balance": return showTopUp(); // an allowance approval will not fix this case "fee-allowance": return showApproveOperationalFee(); case "submit-unconfirmed": return showReconcilePending(getGaslessUnconfirmedSubmit(err)); default: return showDiagnostics(parseGaslessErrorDetail(err)); }
ReasonWhat the user can do
"payer-balance"Top up the billing account’s SYMMIO collateral. Approving more allowance does nothing.
"fee-allowance"Approve more with approveOperationalFee.
"free-quota"Wait for the UTC reset, or pay from the wallet.
"fee-limit"The fee policy refused it; refresh the policy before rebuilding.
"failed-operation"Decode the entry with decodeGaslessOperationFailure and fix that call.
"nonce-or-deadline"Refresh the nonce and deadline and ask for a new signature.
"deposit-below-minimum" / "deposit-not-above-fees"Fund the deposit address further, or wait for finality.
"idempotency-conflict"Mint a new key; reconcile the workflow the old one created.
"client-schema"A client bug — fix the payload, do not retry.
"rate-limited" / "unauthorized" / "forbidden" / "unknown-instance" / "gateway-unavailable"Gateway-level; see the status table below.
"submit-unconfirmed"Resubmit. Never re-run the intent.
"simulation-reverted"Rebuild, re-sign, new key. Never resend the same signature.
"unknown"Show the raw diagnostics.

Diagnostics for a support report

Capture the environment, protocol instance, service, idempotency key, request id and transaction hash if they exist, the HTTP status and vendor code, and the decoded revert — without credentials.

The error never carries credentials by construction: a gasless HTTP failure keeps no request headers and no request body. Its cause is a sanitized snapshot of the failure (name, message, code, status, method, url), and the URL drops any userinfo and query string.

responseData is a different matter. A FastAPI 422 echoes the rejected input in each detail[].input and ctx — for a missing field that is the whole parent object, which can include the signed operations and their signatures, and a signature the service rejected was never executed, so it stays valid until its deadline. GASLESS_SUBMIT_UNCONFIRMED’s responseData deliberately holds the signed body too. Before sending a gasless error to third-party logging, drop or redact responseData; log a 422 through parseGaslessErrorDetail(err)?.validationErrors, which keeps only loc, msg and type.

Gateway statuses

The gateway answers some failures itself, before any service sees the request, with a { "error": "…" } body instead of the service’s { "detail": … } envelope; parseGaslessErrorDetail(err)?.gatewayError holds that string. Every HTTP failure is a SymmApiError with its status:

StatusMeaningWhat to do
0No response: a network failure, or a submit that ran past execution.submitTimeoutMs. In a browser, also a CORS rejection of the app’s Origin.Ambiguous for a submit: resend the same bytes under the same key. A CORS rejection persists until the gateway allows the Origin.
400 with gatewayErrorThe URL and a protocol-instance selector disagree, or the selector is invalid.Fix the url / protocolInstance pairing.
401Invalid, disabled, conflicting or malformed credentials, or no key at all where the deployment has turned anonymous access off (gatewayError then starts Missing API key).With an apiKey, fix it: a rejected key never falls back to anonymous access. Without one, the deployment requires a partner key: call it from a server that holds one.
403The client or its access profile lacks permission for this service or protocol instance.Use a key authorized for the instance and service, or none where the anonymous profile is permitted.
404 with gatewayErrorUnknown or disabled protocol instance or service.A configuration error, not an outage: fix protocolInstance. A 404 with the service code NOT_FOUND instead means the instance stores no such request.
422The service rejected the payload’s shape; validationErrors lists each issue.A client bug: fix the request rather than retrying it.
429Rate limit reached. Anonymous clients share 10 requests per second per IP and instance.The SDK resends a submit up to twice, waiting max(retryAfterMs, 1 s + jitter). Exhausted, it is the one transport failure that is provably not accepted — unless it answered the resend of an ambiguous attempt, which ends as GASLESS_SUBMIT_UNCONFIRMED.
502Upstream unavailable, or a protocol-instance identity mismatch — possibly after the body reached the service.Ambiguous, never a rejection. Resend under the same key.
503With the gateway envelope, its configuration is not ready — the request never left the gateway. Without it, an upstream 503 that may have been seen.The envelope form is retried like a 429 and, like it, is fallback-eligible only when no earlier attempt was ambiguous; the bare form is ambiguous.

retryAfterMs is the Retry-After delay in milliseconds, or null. In a browser it is usually null: a cross-origin page reads Retry-After only when the gateway lists it in Access-Control-Expose-Headers, and by default the gateway exposes only X-GasLessQ-Protocol-Instance. Treat null as unknown, never as “retry now” — the SDK’s own retry waits at least a jittered second either way.

Vendor codes

From the service itself, via parseGaslessErrorDetail(err)?.code:

CodeMeaning / action
FEE_POLICY_WOULD_REVERTThe fee or free-quota policy blocks the request; refresh the policy before rebuilding. Fallback-eligible.
INSUFFICIENT_ALLOWANCERaise the billing account’s gateway allowance. Fallback-eligible.
SIMULATION_REVERTEDThe relay could not execute in simulation. Inspect the decoded cause, then correct funding, authorization or payload — never resend the same signature.
DEPOSIT_BELOW_MINIMUMWait for funding or finality, or add collateral.
CHAIN_CONFIGURATION_ERRORThe backend deployment is incompatible; escalate to the operators.
CHAIN_EXECUTION_ERRORAn RPC or contract call failed; inspect rpcError before deciding whether to retry.
IDEMPOTENCY_KEY_CONFLICTHTTP 409: the key already belongs to another wallet selection or workflow. Mint a new key for a new action (isGaslessIdempotencyConflictError).
NOT_FOUNDThe id or hash is not stored in the selected instance. After a 202, expect this briefly — the accept-vs-record race.
INVALID_ADDRESS / INVALID_OPERATION_SELECTORThe payload named something the service will not relay.

A FastAPI schema failure returns 422 with no code at all; read validationErrors.

SDK error codes

CodeKindMeaning
GASLESS_NOT_CONFIGUREDconfigThe chain has no gasless block.
GASLESS_UNSUPPORTED_CONTRACTS_VERSIONconfigThe chain runs pre-perps-core contracts.
GASLESS_URL_INVALID / GASLESS_URL_SERVICE_MISMATCH / GASLESS_PROTOCOL_INSTANCE_REQUIREDconfigThe gasless.url / protocolInstance pairing cannot produce a service base.
GASLESS_URL_LEGACY_ROUTE / GASLESS_PROTOCOL_INSTANCE_CONFLICTconfigThe url is an instance-less /v1/{service} compatibility route, or its path pins a different instance than protocolInstance. See URL forms.
GASLESS_CONFIG_INCOHERENTconfigThe GaslessLayer verifies against a different InstantLayer than the chain config declares — align the addresses before relaying.
GASLESS_NOT_RELAYABLEvalidationAn explicit gasless: true on calldata the relay cannot carry.
GASLESS_ACCOUNT_UNRESOLVEDvalidationA margin relay could not resolve the virtual account’s parent — pass gasless.account.
GASLESS_VALUE_UNSAFEvalidationA uint256 wire field exceeded the JSON safe-integer range.
GASLESS_CHARGER_REQUIRED / OPERATIONAL_FEE_APPROVAL_MISMATCHvalidationAllowance read/write input problems.
GASLESS_EMPTY_BATCHvalidationA fee quote or a relay was asked for an empty batch.
GASLESS_TEMPLATE_ID_INVALIDvalidationA templateId that is not a non-negative safe integer. An unguarded value serializes to JSON null, which the service reads as “no template” and relays as a plain batch.
GASLESS_TEMPLATE_WALLET_ID_UNSUPPORTEDvalidationA template batch named a non-zero wallet id. Templates relay through relayInstantTemplate, which takes no wallet ids — relay gasless-wallet operations as an ordinary batch.
GASLESS_WALLET_ID_INVALIDvalidationA wallet id is not a bigint from 0n through 2^256 - 1. Numbers are rejected, not coerced.
GASLESS_WALLET_OWNER_MISMATCHvalidationAn explicit owner does not own the signerAccount the wallet is derived from. Thrown before the signature prompt, because the operation would revert with InvalidWalletOperationTarget after the user paid for it.
GASLESS_RESUBMIT_BODY_INVALIDvalidationresubmitGaslessRequest was handed something other than a recorded submit.
GASLESS_FREE_QUOTA_EXHAUSTEDapiThe fee quote reverted with DailyFreeOpsLimitExceeded: a billing account spent its daily free operations and the deployment refuses paid ones. Thrown before any signature prompt, so under fallback: "wallet" the transparent mode takes the wallet path. Test with isGaslessFreeQuotaExhaustedError.
GASLESS_FEE_QUOTE_REVERTEDvalidationThe fee quote reverted with any other contract error, named in the message, or with empty data from a GaslessLayer that has the interface (a batch it cannot decode). The batch itself is invalid, so there is no fallback.
GASLESS_LAYER_INTERFACE_UNSUPPORTEDconfigThe configured GaslessLayer has no previewFeeQuote: it predates the multi-wallet interface. An empty-data revert gets this code only when a second read, quoting empty calldata, reverts empty too. No fallback.
GASLESS_RELAY_SUBMIT_FAILED / GASLESS_SETTLEMENT_SUBMIT_FAILEDapiAn HTTP submit failed with a verdict; the vendor detail rides in responseData.
GASLESS_SUBMIT_UNCONFIRMEDapiThe submit’s outcome could not be established — the request may exist server-side. responseData is the replayable GaslessUnconfirmedSubmit; recover it with getGaslessUnconfirmedSubmit and replay it with resubmitGaslessRequest, never through the wallet.
GASLESS_ACCEPTANCE_INVALIDapiA parsed acceptance carried no request_id. The submit path reports this as GASLESS_SUBMIT_UNCONFIRMED instead, because the bytes that produced it can be replayed.
GASLESS_DEPOSIT_WALLET_MISMATCHapiA deposit settlement was accepted for a different wallet id or deposit address than it was submitted for. The parsed receipt, requestId included, rides in responseData; reconcile it before funding or settling anything else.
GASLESS_NONCE_STREAM_BUSYapiAn earlier relay on the same nonce stream is still unaccounted for, so signing now would collide with it. Wait for that request to reach a terminal status, or for its signature deadline to pass.
GASLESS_RELAY_REJECTED / GASLESS_RELAY_FAILED / GASLESS_RELAY_REVERTEDapiThe workflow ended rejected / failed / reverted. Thrown by the transparent dispatcher and by confirmGaslessRequest — so by every React relay hook, which confirms by default. The terminal record rides in responseData. Only raw getGaslessRequest polling surfaces these as statuses instead.
GASLESS_SUCCEEDED_WITHOUT_TXapiA succeeded record carried no transaction hash — a service invariant break.
GASLESS_PARTYB_UNSUPPORTEDvalidationA PartyB delegation grant cannot be relayed; the signed-operation encoder has no PartyB form. Send it from the wallet.
GASLESS_BROADCAST_TIMEOUT / GASLESS_TERMINAL_TIMEOUTapiA wait budget ran out; the request keeps running server-side — keep polling its requestId.
GASLESS_WAIT_ABORTEDapiThe wait’s AbortSignal fired.
GASLESS_STATUS_FETCH_FAILED / GASLESS_TRANSACTIONS_FETCH_FAILEDapiA status read failed (including the brief post-submit 404 race).
GASLESS_STATUS_UNKNOWNapiThe service returned a status outside the documented lifecycle.
GASLESS_ATTEMPT_STATUS_UNKNOWNapiAn attempt row in getGaslessRequestTransactions carried a status outside the documented set. The request’s own status is unaffected — read the outcome from getGaslessRequest; only the attempts list throws, so a mistyped attempt can never render as a confirmed one.
GASLESS_INSTANCE_MISMATCHapiA response came from a different protocol instance than the config expects — a staging/production cross-wire; fail closed. On a 2xx the parsed body stays in responseData, because its request_id is the only handle on a request that was accepted somewhere. Behind a proxy root, only a forwarded header is checked.
GASLESS_WALLET_UNAVAILABLEapiThe gateway returned the zero address for an owner’s wallet.
Last updated on