Skip to main content

Error Handling

Every error the SDK throws is a GSwapSDKError with a stable code and a flat object of named params. The SDK owns no user-facing text: an app maps code + params to its own localized string.

GSwapSDKError extends GalaSDKError, the base class in @gala-chain/gala-core-sdk. Errors the core layer throws itself (a signer, the fetch requestor's TRANSPORT_ERROR / REQUEST_TIMEOUT, the uniqueKey checks) are plain GalaSDKErrors, not instanceof GSwapSDKError. Do not test instanceof GSwapSDKError: use isGSwapError(error, code?) from this package or isGalaError(error, code?) from core. Both recognise every error of either catalog.

FieldWhat it isStable?
codeThe catalog code (NON_CANONICAL_PAIR, RATE_LIMITED, ...). The only thing to branch on.yes
paramsNamed placeholders of that code: only string, number and boolean, never an object, never a secret. A param may be a closed set of string tokens (INVALID_PRIVATE_KEY.reason, SUBMISSION_OUTCOME_UNKNOWN.reason): the matching const array (INVALID_PRIVATE_KEY_REASONS, ...) lists every member so you can map each one. Feed them to your i18n library.yes, per code
messageEnglish, generated from the catalog template and params, for logs and developers.no: never show it, never match on it
status, url, uniqueKey, requestTransactionId, applyTransactionId, blockNumber, retryAfterMsFacts about the failure that are not placeholders.yes
body, bodyTextThe failing response: parsed JSON in body, raw text of a non-JSON answer in bodyText. Diagnostics only.server-defined
causeThe error that caused this one (a transport failure, a wallet rejection).-
notFoundtrue for HTTP 404, NO_POOL_AVAILABLE and UNKNOWN_TOKEN.yes
phase'pre_send', 'in_flight' or 'post_send': how far the operation got (see "Where the failure happened"). isPreSendError(error) reads it.yes, per code
shadowtrue when the error came from the dark (shadow) path (see "Dark swaps").yes

The server's own English (a gateway or chain message) reaches you in two places only: the raw body, and the serverMessage param of every code that relays it (HTTP_ERROR, GATEWAY_REPORTED_ERROR, the gateway bounces, CHAIN_REJECTED, CONFIRMATION_FAILED, ...). serverMessage is read from the failing body in one fixed order (message, Message, error.message, error.Message; the first non-empty string). It is DATA from the server, not SDK text: it is English, it can change, and it is the empty string when the server sent no text or a non-JSON page (the SDK never writes English in its place). An app may relay it next to its own string or ignore it; do not match on it.

The codes that do not carry serverMessage have no server text to carry: a 2xx acknowledgement that is missing something (GATEWAY_DATA_MISSING, GATEWAY_MODE_INVALID, GATEWAY_SYNC_REQUEST_TRADE, MISSING_UNIQUE_KEY, UNIQUE_KEY_MISMATCH, SHADOW_NOT_QUEUED, CONFIRMATION_MALFORMED, CONFIRMATION_KEY_MISMATCH) is judged by its shape, and the quote classifications (NO_POOL_AVAILABLE, INSUFFICIENT_LIQUIDITY, UNKNOWN_TOKEN) are made from the server's text but state their facts as structured params.

Deciding: classifyFailure​

classifyFailure(error) answers "may I offer Retry?" and "could the write have been applied?" from the code, so an app does not re-derive it per code. It returns { outcome, retryable }:

outcomeMeaningretryable
rejectedThe chain refused (CHAIN_REJECTED, a 422 CHAIN_DISPATCH_FAILED): a new request is safe.yes
unknownThe request may have been applied (SUBMISSION_OUTCOME_UNKNOWN, any CONFIRMATION_*, a 2xx that proves nothing, a 409 or 5xx dispatch failure, a write's 5xx). Keep error.uniqueKey and confirm it; never submit under a fresh key.never
not_queuedThe gateway refused the write before queueing it (the bounces, RATE_LIMITED, a write's 4xx, UPDATE_REQUIRED).yes, except SHADOW_NOT_QUEUED, UPDATE_REQUIRED and a write's non-429 4xx
not_sentThe request never left, for a reason outside the call itself: the signer or wallet failed (locked, unreachable, the person declined), the key provider or a token resolver failed, or no key was available yet. SIGNING_FAILED, KEY_PROVIDER_FAILED, TOKEN_RESOLUTION_FAILED, INVALID_PRIVATE_KEY with reason: 'no_key_provided'.yes
invalid_inputThe call cannot work as written (validation, missing signer or wallet, unknown token or pool, INVALID_UNIQUE_KEY, a READ's UPDATE_REQUIRED).never
transientA read or connection failed with no state change.yes

Anything that is not a GSwapSDKError or a GalaSDKError is unknown and not retryable. That is only what escapes AFTER the request left (an app requestor that rejects with its own error, a callback of yours that throws once bytes were sent), or an error your own code raised before it called the SDK: handle those first. retryable is the SDK's floor (a repeat cannot double-apply); an app may still withhold it. The per-code decision is in src/utils/failure_classification.ts and pinned by tests/failure_classification.test.ts; the table above lists the codes per outcome.

Asking about a wrapper, not just an SDK error: failure views​

Apps wrap SDK errors in their own classes. classifyFailure and every guard in this section accept a GSwapSDKError or a plain-object failure view and decide from the same fields, so you do not have to keep the original error beside the wrapper to ask:

import type { FailureOutcome } from '@gala-chain/gala-core-sdk';

interface FailureView {
code: string; // required: a catalog code
params?: Record<string, unknown>; // e.g. { status } of an HTTP_ERROR, { reason } of a SIGNING_FAILED
status?: number; // the HTTP status of the answer
phase?: 'pre_send' | 'in_flight' | 'post_send'; // absent: the catalog's phase for `code`
uniqueKey?: string; // present: it is a WRITE's failure
write?: boolean; // false: it is a READ's failure (see below); absent or true: a write
outcome?: FailureOutcome; // an APP error's declared meaning; read only for a code the catalog does not define
retryable?: false; // withhold-only: takes Retry away from the declared outcome, never grants it
shadow?: boolean;
notFound?: boolean;
}

A class like class V2Failure extends Error { code; params; status; uniqueKey; write } is already one; toFailureView(value) (from @gala-chain/gala-core-sdk, as are the FailureView type, isWriteView and FAILURE_OUTCOMES) shows what the SDK reads (a field of the wrong type reads as absent). The guards that take a view: classifyFailure, isRelayableRefusal, isUnprovenAcknowledgement, isUninterpretedAnswer, isPreSendError, isShadowError, isWalletUserRejection, isNoMarket, mayHaveBeenQueued, pendingSwapFateFor. A value with no string code, a code the catalog does not define (an ethers 'ACTION_REJECTED', an app-only code) or an HTTP_ERROR view with no status is unknown and not retryable. isRelayableRefusal narrows to GSwapSDKError<...> only when called with an SDK error; with a view it answers boolean.

A view is a WRITE unless it says it is a read (fail closed). A view with no uniqueKey and no write: false is judged a write: its 5xx stays unknown and not retryable. Dropping the uniqueKey can therefore never turn a write's 5xx into a retryable transient (a retry would submit a second trade). Only write: false without a uniqueKey is a read; GSwapSDKError.write is false for every failure of an SDK read and true otherwise. Copy code, params, status, uniqueKey and write from the SDK error when you wrap it.

An error your app raises declares what it means. A code the catalog does not define is unknown (the SDK cannot say what happened, and mayHaveBeenQueued is true: a wrong "failed" invites a duplicate). An error raised BEFORE anything was signed or sent says so with outcome: { code: 'POSITION_STALE', outcome: 'not_sent' } classifies as not_sent (retryable) and mayHaveBeenQueued is false. retryable: false withholds Retry from the declared outcome ({ code: 'DID_NOT_QUEUE', outcome: 'not_queued', retryable: false } is NOT_QUEUED_FINAL); true is never read, so a view cannot grant Retry. The declared outcome is read only for a code the catalog does not define: for a catalog code the SDK's own table decides.

After the write is queued: afterQueued​

classifyFailure(error, { afterQueued: true }) is for the step AFTER the gateway acknowledged the write (confirming it, reading its apply result): a new request would be a second trade, so retryable is false for every code, and outcome is unchanged. The one exception is rejected (the chain evaluated the queued write and refused it: CHAIN_REJECTED, a 422 dispatch): that proves it was not applied, so a new request under a fresh key cannot double it and retryable keeps its answer (true).

Where a confirmation stopped: confirmationStateOf(error) is 'aborted' (your AbortSignal ended the wait), 'timeout' or 'failed' (or undefined when it is not a failed confirmation), and isConfirmationAborted / isConfirmationTimeout are the two guards; a screen never matches CONFIRMATION_* codes itself. All of them are unknown outcomes: confirm the same key.

Two questions every app asks: isNoMarket and mayHaveBeenQueued​

isNoMarket(error) is true for NO_POOL_AVAILABLE, INSUFFICIENT_LIQUIDITY, UNKNOWN_TOKEN and a READ answered 404 (error.notFound): show "no market for this pair", not an error. A write that got a 404 is a missing route, not a missing market. mayHaveBeenQueued(error) is classifyFailure(error).outcome === 'unknown': keep error.uniqueKey and confirm it, never submit again under a fresh key (a value the SDK cannot judge is true too).

An HTTP status you read yourself: GSwapSDKError.fromStatus​

For a resolved HTTP answer the SDK did not read (a v1 endpoint, an interceptor's rejection), GSwapSDKError.fromStatus(status, { url?, uniqueKey?, write?, serverMessage?, body?, retryAfterMs? }) builds the error the SDK would have thrown: UPDATE_REQUIRED for 426, HTTP_ERROR otherwise, phase post_send. A uniqueKey makes it a write (classifyFailure: a 5xx is unknown, a 426 not_queued); without one it is a read (write: false on the error: a 5xx is transient, a 426 invalid_input). write: true with no key throws INVALID_UNIQUE_KEY.

Classifying a step of your own: classification and notSentClassification​

An app step that runs before it calls the SDK (a quote refresh before signing) is not an SDK call, so classifyFailure cannot classify it. Build its classification with the SDK's type and values, not a literal:

import { classification, notSentClassification, type FailureClassification } from '@gala-chain/gswap-sdk';

const refreshFailed: FailureClassification = notSentClassification(); // { outcome: 'not_sent', retryable: true }
const bad: FailureClassification = classification('invalid_input'); // { outcome: 'invalid_input', retryable: false }

classification(outcome) sets retryable to the SDK's default for that outcome: false for unknown and invalid_input, true for the others. FailureClassification is exported by this package; FailureOutcome and FAILURE_OUTCOMES are exported by @gala-chain/gala-core-sdk, so an app can type a Record<FailureOutcome, ...> and have a new outcome fail to compile until it decides it.

An app may take Retry away from an outcome, never grant it: classification('not_queued', { retryable: false }), exported frozen as NOT_QUEUED_FINAL, is the SDK's own value for a refusal the gateway will repeat (SHADOW_NOT_QUEUED, the version wall, a write's 4xx). Use it as it is instead of spreading classification('not_queued') and overriding retryable.

Where the failure happened: error.phase and isPreSendError​

Every GSwapSDKError carries phase, measured against the request the operation exists to send (for a write, the POST with the signed DTO):

phaseMeaningConsequence
pre_sendNOTHING was transmitted: validation, symbol resolution, the signer, the wallet, a key provider failed.The write cannot have been queued. The outcome is invalid_input (change the input) or not_sent (repeat is safe). It is never unknown or rejected.
in_flightThe request left (or may have) and no answer was read: a timeout, a broken connection.For a write the outcome is unknown; for a read it is transient.
post_sendAn answer was read, or the write was already accepted: a refusal, an acknowledgement that proves nothing, an unusable body, any confirmation failure.Decided by the code (rejected, not_queued, unknown).

Each catalog entry declares the phase of its code (the table below has a column for it), and a throw site that can see the same code in another phase refines it. isPreSendError(error) reads it.

An app never needs an error class of its own to say "nothing was sent". Every point where the SDK calls code the app supplied before the request leaves wraps what that code throws into a catalogued pre-send error with the original as cause:

App-supplied codeFailure becomes
signer.signObject (swaps, every position write, the fee DryRun)SIGNING_FAILED with params.method and params.reason (user_rejected when the wallet says the person declined: code 4001, ACTION_REJECTED, or the wallet's own "User rejected ..." text; else failed), and error.walletCode (the wallet's own code: a number such as 4001 or a string such as 'ACTION_REJECTED', undefined when the wallet set none; a typed field on the error, not a param, and never in message)
the key provider of createLazyPrivateKeySigner (from @gala-chain/gala-core-sdk)KEY_PROVIDER_FAILED; a provider that returns nothing is INVALID_PRIVATE_KEY with reason: 'no_key_provided'. Both are not_sent.
a TokenResolver / the registry read it makes through your requestorTOKEN_RESOLUTION_FAILED with params.token
a signed body that cannot be serializedSIGNING_FAILED (checked before the POST, so a transport hook can never call it "maybe queued")

An SDK error those callbacks throw themselves (for example SIGNER_ADDRESS_MISMATCH) keeps its code. Nothing is wrapped AFTER the request left: a rejection of your own httpRequestor that the SDK cannot read stays what it is (use isTransportFailure and answerOfRejection below). The pending-swap store never throws and the SDK never calls it, and there is no uniqueKey generator hook, so neither needs a wrapper.

Relaying the gateway's answer: isRelayableRefusal and isUninterpretedAnswer​

isRelayableRefusal(error) is true for the errors whose params.serverMessage is the gateway's own answer to this write and may be shown as sent: GATEWAY_REPORTED_ERROR, the gateway bounces (METHOD_NOT_ALLOWED, SIGNATURE_INVALID, SIGNER_MISMATCH, DTO_INVALID, BOUNDS_VIOLATION, SYMBOL_CONFLICT, RATE_LIMITED, GATEWAY_REJECTED) and CHAIN_DISPATCH_FAILED. Called with an SDK error it narrows it, so error.params.serverMessage is typed (with an unknown or a view it answers boolean). Show it when it is not empty and your own line when it is; never the SDK's English message. CHAIN_REJECTED (worded by where it is shown), HTTP_ERROR and the five unproven acknowledgements are not relayable. A CHAIN_DISPATCH_FAILED with status 409 is the "uniqueKey already consumed" replay (classifyFailure: unknown): an app that words it itself asks isUniqueKeyConsumed(error) first.

import { isGSwapError, isRelayableRefusal } from '@gala-chain/gswap-sdk';

function present(error: unknown): void {
if (isGSwapError(error) && isRelayableRefusal(error)) show(error.params.serverMessage || t('swap.requestFailed'));
}

isUninterpretedAnswer(error) answers a different question: did the gateway ANSWER 2xx with something the SDK refused to interpret, so that the answer itself (error.body / error.bodyText) is the thing to read? It is true for the five unproven acknowledgements and GATEWAY_REPORTED_ERROR, and replaces an app's own isUnprovenAcknowledgement(e) || isGSwapError(e, 'GATEWAY_REPORTED_ERROR').

Did the person decline? isWalletUserRejection​

For a signature the SDK asks for, read SIGNING_FAILED and params.reason === 'user_rejected'. For wallet code of your own (a connect or registration flow, a v1 write path, an analytics hook), isWalletUserRejection(value) runs the same test the SDK applies to what a signer throws, so you keep no matcher of your own:

import { isWalletUserRejection } from '@gala-chain/gala-core-sdk';

try {
await wallet.connect();
} catch (error) {
if (!isWalletUserRejection(error)) report(error); // a decline is the person's choice, not a failure
}

It decides from the wallet's own fields first: code 4001 (EIP-1193), 'ACTION_REJECTED' (ethers) or 'USER_REJECTED', or name === 'UserRejectedRequestError' (viem). Only when those are absent does it read the wallet's text (message, Message, shortMessage, details, errorDetails, reason, three levels deep, or a bare string) for "user rejected", "user denied", "user cancelled", "request rejected", "rejected by user", "rejected the request", "cancelled" / "canceled" or a "signature ... cancel" sentence. The wording fallback is a last resort for wallets that do not set codes: it reads a third party's text and is deliberately broad, so wherever you control the wallet, set a code. An SDK error is never read by its text (SIGNING_FAILED with reason user_rejected is a decline; every other SDK error is not, and the same holds for a plain-object view of one: { code: 'SIGNING_FAILED', params: { reason: 'user_rejected' } }). walletDeclineCode(value) reads the wallet's code the same way error.walletCode is filled.

Import it from the light subpath in app-shell code. @gala-chain/gala-core-sdk/wallet holds only the dependency-free wallet helpers (isWalletUserRejection, walletDeclineCode, isGalaErrorLike) and never loads the catalog or the rest of the SDK (under 2 kB, against 85 kB for the same function through the rc.6 main entry). The @gala-chain/gswap-sdk/wallet subpath was removed in 2.0.0:

import { isWalletUserRejection } from '@gala-chain/gala-core-sdk/wallet';

The main entry's browser build is tree-shakeable and every failure decider (classifyFailure, mayHaveBeenQueued, pendingSwapFateFor, isNoMarket, isRelayableRefusal, ...) is shell-safe too, each with a measured byte budget. Bundle size lists what each helper costs an app shell that also lazy-loads the whole SDK, and how the budgets are tested.

Dark swaps: error.shadow and isShadowError​

Everything swapShadow, submitSwapShadow and ChainGateway.submitShadow throw has error.shadow === true (validation, the signer, the transport, SHADOW_NOT_QUEUED), and isShadowError(error) also answers for a non-SDK object that escaped (your requestor's own rejection; a thrown primitive cannot be remembered). The classification is the same as on the public path, so an app that never retries or falls back publicly after a dark swap applies that on top: retryable && !isShadowError(error). A shadow gateway refusal that resolves ({ ok: false, kind }) is an outcome, not an error; its kinds are SHADOW_FAILURE_KINDS (unavailable, the only retryable one; disabled, the route answered 400, for example SHADOW_UNSUPPORTED_TYPE for a method that is not a swap; rejected, with the gateway's message; unknown). Shadow Swaps are enabled in every environment, so disabled never means the feature is off.

Reading an HTTP answer out of a rejection: answerOfRejection​

A requestor on an HTTP client that rejects every non-2xx (an axios interceptor, a version wall) loses the answer the SDK reads. new ChainGateway({ answerOfRejection }) maps such a rejection to { status, body, headers? } and the write is handled exactly as if the answer had been resolved: DTO_INVALID, RATE_LIMITED, CHAIN_DISPATCH_FAILED, ... or HTTP_ERROR for an uncoded body, carrying the signed uniqueKey and classified as a write. isTransportFailure is asked first. For a requestor that wraps fetch and rejects with its own errors, pass isTransportFailure: isFetchTransportFailure (from @gala-chain/gala-core-sdk): a TypeError (any engine's wording) or a NetworkError means no answer arrived; an AbortError or TimeoutError never does (the request layer reports those itself). isUnprovenAcknowledgement(error) is true for the five 2xx codes that do not prove a write was queued (one line of copy, outcome unknown).

Narrowing by code​

isGSwapError(error, code) narrows error.params to that code's params. It accepts a code of either catalog and any GalaSDKError, so it also answers for an error core threw:

import { isGSwapError } from '@gala-chain/gswap-sdk';

try {
await gSwap.swaps.swap('GALA', 'GUSDC', 3000, { exactIn: '100' });
} catch (error) {
if (isGSwapError(error, 'RATE_LIMITED')) {
wait(error.retryAfterMs ?? 60_000);
} else if (isGSwapError(error, 'NON_CANONICAL_PAIR')) {
show(t('errors.NON_CANONICAL_PAIR', error.params)); // { token0, token1 }
} else if (isGSwapError(error)) {
// error.code is the union of every catalog code: switch on it exhaustively.
show(t(`errors.${error.code}`, error.params));
} else {
throw error;
}
}

isGSwapError(error) with no code narrows to AnyGSwapError, the union of one GSwapSDKError<C> per code, so error.code === 'INVALID_FEE' narrows error.params to { field, value, allowed }.

Localizing: i18next​

gswapErrorTemplates maps every DEX code to its English template, {placeholders} included, and galaErrorTemplates (from @gala-chain/gala-core-sdk) does the same for the signing, transport, wallet, typed-data and uniqueKey codes; merge the two. Use them as the English seed of your resource file and translate it; i18next interpolates with {{name}}, so convert the markers once when you build the seed:

import i18next from 'i18next';
import { isGSwapError, gswapErrorTemplates } from '@gala-chain/gswap-sdk';
import { galaErrorTemplates } from '@gala-chain/gala-core-sdk';

// en/errors.json, generated once from the SDK's templates:
const en = Object.fromEntries(
Object.entries({ ...galaErrorTemplates, ...gswapErrorTemplates }).map(([code, template]) => [
code,
template.replace(/\{(\w+)\}/gu, '{{$1}}'),
]),
);

// de/errors.json, hand-written:
// { "NON_CANONICAL_PAIR": "Das Paar {{token0}}/{{token1}} ist nicht in kanonischer Reihenfolge." }

function errorText(error: unknown): string {
if (!isGSwapError(error)) return i18next.t('errors.UNKNOWN');
return i18next.t(`errors.${error.code}`, {
...error.params,
defaultValue: i18next.t('errors.UNKNOWN'),
});
}

Localizing: ICU MessageFormat (FormatJS)​

ICU uses {name} exactly as the SDK's templates do, so the English seed is usable as is, and plurals or selects are possible where a language needs them:

import { createIntl } from '@formatjs/intl';
import { gswapErrorTemplates, isGSwapError } from '@gala-chain/gswap-sdk';
import { galaErrorTemplates } from '@gala-chain/gala-core-sdk';

const templates: Record<string, string> = { ...galaErrorTemplates, ...gswapErrorTemplates };
const intl = createIntl({
locale: 'fr',
messages: {
NON_CANONICAL_PAIR: 'La paire {token0}/{token1} n’est pas dans l’ordre canonique.',
INVALID_FEE:
'{field} : {value} n’est pas un palier de frais valide ({allowed}).',
},
});

function errorText(error: unknown): string {
if (!isGSwapError(error)) return intl.formatMessage({ id: 'UNKNOWN', defaultMessage: 'Something went wrong' });
return intl.formatMessage(
{ id: error.code, defaultMessage: templates[error.code] ?? error.code },
error.params,
);
}

params never contain a nested object or an Error, so they can be passed straight through. A per-field validation error carries the field name and the offending value or limits ({ field: 'fee', value: 250, allowed: '0,500,3000,10000' }, lists pre-joined into a string), so a form can attach the message to the right input.

Mapping a chain refusal: chainErrorKeyOf and the gateway's errorKey​

A refusal by the chain reaches you in two shapes, and both carry the chain's own machine-readable ErrorKey so an app maps the key and never the English:

  • CHAIN_REJECTED: a queued write's apply result with Status: 0 (or a synchronous write the chain refused). params.errorKey is the apply result's ErrorKey (CHAIN_ERROR when it carried none) and params.serverMessage its Message.
  • CHAIN_DISPATCH_FAILED: the gateway failed the dispatch itself. The swap backend puts the chain's key in a top-level errorKey field of its failure body, present only when the chain supplied one, and the SDK copies it to params.errorKey. It is the empty string when the body has none (an older deployment, or a failure the chain gave no key), so treat '' as "no key", not as a key.

chainErrorKeyOf(error) reads the key from either, from an SDK error or a failure view: it is params.errorKey of a CHAIN_REJECTED or CHAIN_DISPATCH_FAILED, and undefined for any other code and for the empty key. It is shell-safe (no catalog), so a presenter can map a key without the SDK:

import { chainErrorKeyOf, isGSwapError } from '@gala-chain/gswap-sdk';

function chainRefusalText(error: unknown): string | undefined {
const key = chainErrorKeyOf(error);
if (key === undefined) return undefined; // not a chain refusal, or the gateway sent no key
const serverMessage = isGSwapError(error, 'CHAIN_REJECTED') ? error.params.serverMessage : '';
return t(`chain.${key}`, { defaultValue: serverMessage });
}

What an app should treat as what​

  • Rejected on chain: only CHAIN_REJECTED. params.errorKey is the chain ErrorKey; fix the business condition and never retry the same key.
  • Outcome unknown, never resubmit, confirm error.uniqueKey later: SUBMISSION_OUTCOME_UNKNOWN, CONFIRMATION_TIMEOUT, CONFIRMATION_ABORTED, CONFIRMATION_FAILED, CONFIRMATION_MALFORMED and CONFIRMATION_KEY_MISMATCH. Branch on "is it CHAIN_REJECTED?", not on "is it one of the unknown ones": any code you did not list must not be treated as a rejection.
  • Not queued (the write did not happen): MISSING_UNIQUE_KEY, UNIQUE_KEY_MISMATCH, GATEWAY_SYNC_REQUEST_TRADE, GATEWAY_MODE_INVALID, GATEWAY_DATA_MISSING, GATEWAY_REPORTED_ERROR and the gateway bounces (METHOD_NOT_ALLOWED, SIGNATURE_INVALID, SIGNER_MISMATCH, DTO_INVALID, BOUNDS_VIOLATION, SYMBOL_CONFLICT, RATE_LIMITED, GATEWAY_REJECTED). The exception is CHAIN_DISPATCH_FAILED with error.status === 409: the uniqueKey was already consumed, so confirm the original key and do not retry.
  • Transport: TRANSPORT_ERROR (nothing answered; the original error is cause), REQUEST_TIMEOUT, HTTP_ERROR (params.status). A caller's own abort is not wrapped: it comes back as the abort it is.

Rate limiting​

The write limiter allows 30 writes per recovered signer in a sliding 60-second window, matching signer-rate-limiter.ts in the defi-backend. The SDK does not automatically retry writes. Honor the server's Retry-After header through error.retryAfterMs; if it is absent, wait for the window to pass before trying again with the same intent.

SIGNER_MISMATCH is only relevant to contract operations that explicitly name an actor, such as a token or fee operation. The v2 DEX operations have no signerField: their identity is the signer recovered from the signature. X-Wallet-Address is an attribution hint, not an identity assertion.

The catalog​

Generated from src/classes/gswap_error_catalog.ts (npm run docs:error-catalog); a test fails while this table differs from the catalog. The template column is the English the SDK's message is generated from, shipped as gswapErrorTemplates for use as a seed or fallback. "App flows" is the flow of the two Gala apps that maps the code today.

Since 2.0.0 this table lists gswap-sdk's own DEX codes. The signing, transport, wallet, typed-data and uniqueKey codes (INVALID_PRIVATE_KEY, SIGNING_FAILED, KEY_PROVIDER_FAILED, REQUEST_TIMEOUT, TRANSPORT_ERROR, INVALID_TIMEOUT, PERSONAL_SIGN_*, EIP712_*, TYPED_DATA_*, INVALID_UNIQUE_KEY, INVALID_UNIQUE_KEY_PREFIX, CSPRNG_UNAVAILABLE, ...) moved with the signing code to @gala-chain/gala-core-sdk, which documents them in its docs/error-catalog.md and ships their templates as galaErrorTemplates. A GSwapSDKError can carry a code of either catalog, classifyFailure answers for both, and isGSwapError recognises an error core threw itself (a signer, the fetch requestor) as well as a GSwapSDKError.

{/* error-catalog:start (generated by npm run docs:error-catalog) */}

codephaseparamsEnglish template (seed only)when thrown
HTTP_ERRORpost_sendstatus:number, url:string, serverMessage:string (not in message)Unexpected HTTP error {status} from {url}.An HTTP failure with no readable relay or chain answer (a proxy 502, a WAF page, an empty 500); the raw text is bodyText.
UPDATE_REQUIREDpost_sendstatus:numberThe backend refused this app version and requires an update (HTTP {status}); nothing was queued.The backend answered HTTP 426: it refuses this app or extension version (the version wall). Thrown for a 426 on any read or write request, whatever the body says; the body is error.body. A write is not_queued and a read invalid_input, never retryable: only an update helps. A confirmation read that gets a 426 stays CONFIRMATION_FAILED (params.status 426) because the write it confirms may be applied.
REQUEST_FAILEDin_flightoperation:stringThe {operation} request failed.A read failed in a way that is not an SDK error (an unreadable body, an app requestor's own rejection); params.operation is pool, quote or position. The failed error is cause.
METHOD_NOT_SUPPORTEDpre_sendmethod:stringThe method {method} is not supported; swaps are queued through RequestTrade.The retired synchronous Trade method was submitted.
SUBMISSION_OUTCOME_UNKNOWNin_flightreason:timeout|transportThe submission outcome is unknown ({reason}): the request may have reached the gateway. Confirm before retrying.The write POST got no answer: params.reason is timeout (it exceeded the gateway timeout) or transport (the connection failed or the body broke off; also a rejection the app names in ChainGatewayOptions.isTransportFailure). The swap may have been accepted: confirm error.uniqueKey; whether to offer Retry (a new key) is the app's policy. Reads keep TRANSPORT_ERROR.
GATEWAY_REPORTED_ERRORpost_sendserverMessage:stringThe gateway reported an error for this write: {serverMessage}A 2xx answer whose envelope says error: true; the envelope is error.body.
GATEWAY_DATA_MISSINGpost_send-The gateway response is missing its data object.A 2xx answer without a data object.
GATEWAY_MODE_INVALIDpost_send-Gateway response must contain data.mode="sync" with data.result, or data.mode="queued".A 2xx answer whose data.mode / result / ids are not a valid sync or queued acknowledgement.
GATEWAY_SYNC_REQUEST_TRADEpost_send-The gateway executed RequestTrade synchronously, which this SDK refuses; RequestTrade must be queued.RequestTrade answered as executed synchronously, which the SDK refuses.
MISSING_UNIQUE_KEYpost_send-The gateway acknowledged RequestTrade without echoing its uniqueKey, so it cannot be tied to the signed request.A queued RequestTrade acknowledgement that does not echo a uniqueKey.
UNIQUE_KEY_MISMATCHpost_send-Gateway response data.uniqueKey must match the signed RequestTrade uniqueKey.A queued RequestTrade acknowledgement for another uniqueKey than the signed one.
SHADOW_NOT_QUEUEDpost_send-The swap backend did not queue the shadow request.A shadow RequestTrade success that does not queue the signed key.
SHADOW_SUBMIT_NEEDS_WALLET_ADDRESSpre_send-A shadow submission needs a walletAddress (the X-Wallet-Address header): pass one to the GSwap constructor or to this call.submitShadow without a wallet address.
METHOD_NOT_ALLOWEDpost_sendserverMessage:stringThe gateway does not expose this contract method: {serverMessage}Gateway bounce 403: the contract method is not exposed.
SIGNATURE_INVALIDpost_sendserverMessage:stringThe gateway could not verify the signature: {serverMessage}Gateway bounce 401: the signature cannot be verified.
SIGNER_MISMATCHpost_sendserverMessage:stringThe recovered signer does not match an actor named by the operation: {serverMessage}Gateway bounce 403: recovered signer differs from a named actor.
DTO_INVALIDpost_sendserverMessage:stringThe gateway rejected the DTO shape: {serverMessage}Gateway bounce 400: DTO shape or field validation failed.
BOUNDS_VIOLATIONpost_sendserverMessage:stringThe gateway rejected a numeric, tick, symbol or range bound: {serverMessage}Gateway bounce 400: a numeric, tick, symbol or range bound failed.
SYMBOL_CONFLICTpost_sendserverMessage:stringThe CreatePool symbol is already owned by another class: {serverMessage}Gateway bounce 409: the CreatePool symbol belongs to another class.
RATE_LIMITEDpost_sendserverMessage:stringThe gateway rate-limited this signer: {serverMessage}Gateway bounce 429: 30 writes / 60 s exceeded; error.retryAfterMs when sent.
CHAIN_DISPATCH_FAILEDpost_senderrorKey:string, serverMessage:stringThe chain dispatch failed (chain error key "{errorKey}"): {serverMessage}The chain refused or failed the dispatch. params.errorKey is the chain's own key when the gateway body carries errorKey ("" when it does not, as an older gateway): map it, not the text. HTTP 409 means the uniqueKey was already consumed (confirm the original key, never retry with a new one); 422 is a definite refusal; another 5xx is an upstream outage and the dispatch may have landed.
GATEWAY_REJECTEDpost_sendgatewayCode:string, serverMessage:stringGateway rejected {gatewayCode}: {serverMessage}Any gateway bounce code the SDK has no entry for; params.gatewayCode carries it.
CHAIN_REJECTEDpost_senderrorKey:string, serverMessage:stringGalaChain error {errorKey}: {serverMessage}The chain refused: a Status: 0 apply result, a sync write result, or an HTTP error body with ErrorKey. params.errorKey is the chain key.
INVALID_CHAIN_RESPONSEpost_sendresource:stringBackend returned an invalid {resource}.A read answer that is not the expected shape; params.resource names it, the answer is error.body.
INVALID_POSITION_RESPONSEpost_sendreason:stringBackend returned an invalid position ({reason}).A /position answer that is not a position.
CONFIRMATION_TIMEOUTpost_send-Transaction confirmation timed out; the trade may still apply. Confirm the same uniqueKey again later instead of resubmitting.No terminal apply result before the deadline. The trade may still apply: confirm the same key later, never resubmit.
CONFIRMATION_ABORTEDpost_send-Transaction confirmation was aborted.The caller's signal aborted confirmation; the outcome is unknown.
CONFIRMATION_FAILEDpost_sendstatus:number, serverMessage:string (not in message)Transaction confirmation failed with HTTP {status}.The apply-results read got a terminal HTTP status (params.status); the outcome is unknown.
CONFIRMATION_MALFORMEDpost_send-Transaction confirmation returned a malformed body.A 200 apply result that is not a usable body; the outcome is unknown.
CONFIRMATION_KEY_MISMATCHpost_send-Apply result key did not match the requested key.A 200 apply result for another key; the outcome is unknown.
NO_SIGNERpre_send-This method requires a signer. Please provide a signer to the GSwap constructor.A write on a client built without a signer.
NO_WALLET_ADDRESSpost_send-Cannot confirm this write: no walletAddress was provided to the GSwap constructor, so the resulting position cannot be looked up to confirm it landed on chain.Confirming a liquidity write without a configured walletAddress.
NO_POOL_AVAILABLEpost_sendtokenIn:string, tokenOut:string, fee:stringNo pool is available for {tokenIn}/{tokenOut} (fee tier {fee}).A quote the backend answered with 404 / "no pools" / "pool does not exist": the pair has no market. notFound is true. A token with no trading symbol is UNKNOWN_TOKEN instead.
INSUFFICIENT_LIQUIDITYpost_sendtokenIn:string, tokenOut:string, fee:stringThe selected pool has insufficient liquidity for {tokenIn}/{tokenOut} (fee tier {fee}).A quote the backend answered with 400 "Insufficient liquidity". notFound is false.
UNKNOWN_TOKENpre_sendtoken:stringToken {token} is not registered on the DEX contract: it has no trading symbol.A token the symbol registry does not know: a symbol lookup, or a quote the backend refused with Token <token> has no trading symbol on the new DEX contract (params.token is the token as the caller gave it; the answer is error.status / error.body). notFound is true.
SYMBOL_NOT_FOUNDpre_sendtoken:stringToken {token} could not be resolved to a trading symbol.A resolver that returned no trading symbol.
STREAM_API_KEY_UNUSABLEpre_send-GdexStreamConfig.apiKey needs a non-empty key and an explicit webSocketImpl that accepts upgrade headers as its third argument (the "ws" package, or React Native). A browser WebSocket cannot send x-api-key, so the key would be silently dropped.GdexStreamConfig.apiKey without a usable webSocketImpl.
STREAM_WEBSOCKET_UNAVAILABLEpre_send-GdexStreamService requires a standards-compatible WebSocket constructor. Node.js versions before 22 must provide one with config.webSocketImpl (for example, from the "ws" package).No WebSocket constructor for the realtime stream.
STREAM_NOT_CONNECTEDpre_send-GDEX stream is not connected. Call connect() first.A stream request before connect().
STREAM_DISCONNECTEDin_flight-GDEX stream disconnected.disconnect() while requests were pending.
STREAM_REQUEST_TIMEOUTin_flightmethod:string, timeoutMs:numberGDEX request "{method}" timed out after {timeoutMs} ms.A stream request got no answer in time.
STREAM_CONNECT_FAILEDin_flighturl:stringFailed to connect to GDEX stream at {url}.The socket errored before opening.
STREAM_CLOSED_BEFORE_CONNECTin_flightcloseCode:numberGDEX stream closed before connecting (code {closeCode}).The socket closed before opening.
STREAM_CONNECTION_CLOSEDin_flightcloseCode:numberGDEX stream connection closed (code {closeCode}).The socket closed while requests were pending.
TOKEN_RESOLUTION_FAILEDpre_sendtoken:stringToken {token} could not be looked up; nothing was sent.A token resolver, or the HTTP stack it reads the registry through, threw something that is not an SDK error while a swap or position write was being built. params.token names the token (a pair is a/b); the original error is cause. not_sent: Retry is safe.
MISSING_WALLET_ADDRESSpre_send-Invalid wallet address: No wallet address provided. Either provide a wallet address to the function you are calling, or set one when instantiating GSwapSDK.No wallet address was given where one is required.
NON_CANONICAL_PAIRpre_sendtoken0:string, token1:stringThe pair {token0}/{token1} is not in canonical order: token0 must sort before token1.A pair not in canonical order (token0 must sort before token1) was about to be signed.
UNORDERABLE_SYMBOLSpre_sendreceived:stringUnable to order trading symbols: expected a pair of symbols, got {received}.A pair-ordering helper got something that is not a pair.
INVALID_TOKEN_CLASS_KEYpre_sendtokenClassKey:stringInvalid token class key "{tokenClassKey}": expected a collection followed by category, type and additionalKeyA token class key string that is not collection/category/type/additionalKey.
INCOMPLETE_TOKEN_CLASS_KEYpre_sendcompositeKey:string, part:stringToken class key "{compositeKey}" is missing {part}.A class key object with an empty part; params.part names it.
INVALID_TRADING_SYMBOLpre_sendsymbol:stringInvalid trading symbol "{symbol}": must be 1-32 ASCII letters and digits and not a reserved word (Token, Unit, none).A proposed symbol that is not 1-32 ASCII letters/digits or is reserved.
NO_VALID_TRADING_SYMBOLpre_sendclassKey:string, displaySymbol:stringNo valid trading symbol for token class {classKey}: "{displaySymbol}" is not an acceptable symbol and none is registeredNo registered symbol and the display symbol is not proposable.
TOKEN_SYMBOL_NOT_REGISTEREDpre_sendtoken:stringToken {token} is not registered in the trading-symbol registry yet; pass token0Symbol/token1Symbol (the token class symbol) to propose one. A symbol cannot be derived from a class key.CreatePool on an unregistered token without a supplied symbol.
SYMBOL_DISAGREES_WITH_REGISTRYpre_sendtoken:string, suppliedSymbol:string, registeredSymbol:stringSupplied symbol {suppliedSymbol} for {token} disagrees with the registered trading symbol {registeredSymbol}.A supplied symbol that differs from the registered one.
CREATE_POOL_NEEDS_CLASS_KEYpre_sendtoken:stringcreatePool needs each token as a class key; {token} is a bare trading symbol, which does not carry one.CreatePool given a bare symbol instead of a class key.
AMOUNT_IS_JS_NUMBERpre_sendfield:stringInvalid {field}: use a decimal string or BigNumber, not a JavaScript numberA JavaScript number was given where a decimal string is required; params.field names it.
AMOUNT_NOT_FINITEpre_sendfield:string, value:stringInvalid {field}: must be a finite number (got {value})A non-finite or unparseable amount; params.field, params.value.
AMOUNT_NOT_POSITIVEpre_sendfield:string, value:stringInvalid {field}: must be positive (got {value})A zero amount where a positive one is required.
AMOUNT_NEGATIVEpre_sendfield:string, value:stringInvalid {field}: must not be negative (got {value})A negative amount.
PRICE_VALUES_IS_JS_NUMBERpre_send-Invalid price values: use decimal strings or BigNumber values, not JavaScript numbersvalidatePriceValues got JavaScript numbers.
INVALID_PRICE_VALUESpre_sendspotPrice:string, lowerPrice:string, upperPrice:stringInvalid price values: all prices must be finite and positive (spot {spotPrice}, lower {lowerPrice}, upper {upperPrice})A price that is not finite and positive.
INVALID_PRICE_RANGEpre_sendlowerPrice:string, upperPrice:stringInvalid price range: lower price {lowerPrice} must be less than or equal to upper price {upperPrice}lowerPrice above upperPrice.
INVALID_PRICE_BOUNDSpre_sendminPrice:string, maxPrice:stringPrice bounds must be finite and positive (minPrice {minPrice}, maxPrice {maxPrice}).addLiquidityByPrice min/max not finite and positive.
PRICE_BOUNDS_ORDERpre_sendminPrice:string, maxPrice:stringminPrice {minPrice} must be less than or equal to maxPrice {maxPrice}.addLiquidityByPrice minPrice above maxPrice.
INVALID_STARTING_PRICEpre_sendvalue:stringStarting price must be finite and positive (got {value}).A CreatePool starting price that is not finite and positive.
INVALID_SQRT_PRICEpre_sendsqrtPrice:stringCannot invert a non-positive sqrt price ({sqrtPrice}).A non-positive sqrt price could not be inverted.
INVALID_TOKEN_DECIMALSpre_sendfield:string, value:numberInvalid {field}: must be a non-negative integer (got {value})Token decimals that are not a non-negative integer.
TICKS_NOT_INTEGERSpre_sendtickLower:number, tickUpper:numberInvalid tick values: ticks must be integers (got {tickLower} and {tickUpper})Tick bounds that are not integers.
INVALID_TICK_RANGEpre_sendtickLower:number, tickUpper:numberInvalid tick range: tickLower {tickLower} must be less than tickUpper {tickUpper}tickLower not below tickUpper.
TICKS_OUT_OF_BOUNDSpre_sendtickLower:number, tickUpper:number, minTick:number, maxTick:numberInvalid tick range: ticks {tickLower} and {tickUpper} must be between {minTick} and {maxTick}Ticks outside the chain's global min/max.
TICKS_NOT_ALIGNEDpre_sendtickLower:number, tickUpper:number, spacing:numberTicks {tickLower} and {tickUpper} must be aligned to spacing {spacing}.Ticks not aligned to the fee tier's spacing.
INVALID_TICK_SPACINGpre_sendvalue:numberInvalid tick spacing: must be a positive integer (got {value})A tick spacing that is not a positive integer.
INVALID_FEEpre_sendfield:string, value:number, allowed:stringInvalid {field}: {value} is not a supported fee tier; must be one of {allowed}.A fee tier that is not 0, 500, 3000 or 10000; params.field names the option.
EXACT_AMOUNT_REQUIREDpre_sendfield:string{field} is required for this swap.A swap request without exactIn / exactOut.
EXACTLY_ONE_OF_REQUIREDpre_sendfirst:string, second:stringProvide exactly one of {first} or {second}.Neither or both of two mutually exclusive fields.
AT_MOST_ONE_OF_ALLOWEDpre_sendfirst:string, second:stringProvide at most one of {first} or {second}.Both of two mutually exclusive optional fields.
POOL_MISMATCHpre_sendtokenIn:string, tokenOut:string, poolToken0:string, poolToken1:stringtokenIn {tokenIn} and tokenOut {tokenOut} must be the two tokens of the pinned pool {poolToken0}/{poolToken1}.tokenIn/tokenOut are not the two tokens of the pinned pool.
INVALID_DTOpre_sendmethod:string, field:string, expected:string{method} DTO field {field} is invalid (expected {expected}).A DTO handed to submit* has a field of the wrong shape; params.field, params.expected.
DTO_NOT_CANONICALpre_sendmethod:stringThe {method} DTO is not in the form the SDK builders produce; build it with the matching build function and submit that.A DTO handed to submit* is not exactly what the builder produces.
SEARCH_TOO_LONGpre_sendlength:number, maxLength:numbersearch must be at most {maxLength} characters (got {length}).Pool list search over 64 characters.
INVALID_SORT_BYpre_sendvalue:string, allowed:stringsortBy "{value}" is not allowed; it must be one of {allowed}.Pool list sortBy not allowed.
INVALID_SORT_ORDERpre_sendvalue:stringsortOrder "{value}" is not allowed; it must be asc or desc.Pool list sortOrder not asc/desc.
INVALID_PAGEpre_sendvalue:numberpage must be an integer of at least 1 (got {value}).page not an integer of at least 1.
INVALID_LIMITpre_sendvalue:number, min:number, max:numberlimit must be an integer from {min} to {max} (got {value}).limit outside params.min..params.max.
INVALID_MARKET_TOKENpre_sendtoken:stringA market read names a token by a symbol or class key of 1 to 120 letters, digits and . _ $ + / : | - characters (got "{token}").A market token string outside the backend asset-id alphabet or over 120 characters.
INVALID_TIMEFRAMEpre_sendvalue:string, allowed:stringtimeframe "{value}" is not allowed; it must be one of {allowed}.market.getPriceSeries timeframe not 1D / 1W / 1M / 1Y / ALL.
INVALID_SEASON_IDpre_sendvalue:stringseasonId must be an integer of at least 1 (got {value}).leaderboard.getLeaderboard seasonId not an integer of at least 1.
INVALID_POLL_INTERVALpre_sendvalue:stringintervalMs must be a finite number of milliseconds of at least 1 (got {value}).pools.watchNewPools intervalMs not a finite number of at least 1.

{/* error-catalog:end */}