Skip to main content

Error handling

This page tells a production client what to do about each class of error.

tip

Stable.

The errors page lists every error code and the status it answers with. This page gives the action for each class.

Three failure layers​

LayerWhen it firesWhere it shows
AdmissionAt /exchange requestHTTP status + body
CommitAt block commit, after admissionorder_updates / fills WS push, or visible in user_fills / open_orders
NetworkAnywhereTCP error, timeout, partial response

Each layer has different semantics. A mix-up between them is the most common production bug.

Decision tree​

Admission errors​

The server parsed the request and rejected it at admission. The body is the failure envelope: an error object, and no data key.

{ "error": { "code": "ORDER_INVALID_PRICE", "message": "...", "details": { "field": "px", "limit": "100", "actual": "12345" } } }
danger
Branch on error.code, never on error.message

code is the stable contract. message is prose for a human, and any release can reword it. A handler keyed on the sentence breaks without a signal. It breaks in the bad direction: a known rejection reads as an unknown one.

Do not treat a null data as a failure. A read can succeed with no content and answer {"data": null}. Test whether error is PRESENT.

Classerror.codeRetry rule
Client bugINVALID_REQUEST, UNKNOWN_TYPE, ACTION_UNSUPPORTEDDO NOT retry. Fix the code
Signing bugAUTH_BAD_SIGNATURE, AUTH_UNAUTHORIZEDDO NOT retry. Verify chainId / key / owner
Auth stateAUTH_AGENT_FORBIDDENThe approval is missing or expired. Re-approve, then retry
Order shapeORDER_INVALID_PRICE, ORDER_INVALID_SIZE, ORDER_ZERO_SIZE, ORDER_BELOW_MIN_NOTIONALCompute the right value from details.limit, then retry
StateMARGIN_INSUFFICIENT, ASSET_INSUFFICIENT_BALANCE, MARKET_INACTIVE, MARKET_OI_CAP, PRECONDITION_FAILEDAdd margin, or wait for the state to change, then retry
Not foundORDER_NOT_FOUND, MARKET_NOT_FOUND, NOT_FOUNDDo not retry. Check the resource
Server errorINTERNAL, UNAVAILABLERetry with backoff, then report

The full list, with the status and the action for each code, is in errors.

The classes below (ClientBugError, AuthError, …) are an example taxonomy for a hand-written client that calls fetch directly. The TypeScript SDK does not export them. It throws one class, MetaFluxApiError, which carries the error code, and you branch on the code yourself. See TypeScript SDK.

async function handleAdmissionResponse(r: Response) {
const body = await r.json();

// The PRESENCE of `error` is the failure. `data` can be null on a success.
if (!('error' in body)) return { admitted: true, data: body.data };

const { code, message, details } = body.error;

switch (code) {
case 'RATE_LIMITED':
// No retry hint is sent. 20 weight/s refill, so 250 ms buys back one
// weight-5 `/exchange`.
await sleep(250);
return { admitted: false, retry: true };

case 'INTERNAL':
case 'UNAVAILABLE':
await sleep(200);
return { admitted: false, retry: true };

case 'ORDER_INVALID_PRICE':
case 'ORDER_INVALID_SIZE':
// `details.limit` is the grid to round to.
throw new LogicalError(code, message, details);

default:
// Group by prefix so a code added in a later release lands in the right
// arm instead of the unknown one.
if (code.startsWith('AUTH_')) throw new AuthError(code, message);
if (code.startsWith('ORDER_') || code.startsWith('MARGIN_') ||
code.startsWith('MARKET_') || code.startsWith('ASSET_')) {
throw new LogicalError(code, message, details);
}
throw new ClientBugError(code, message);
}
}

Commit errors​

The server admitted the action (202), but it failed at commit. Only the event stream reports this failure.

ErrorCauseRetry?
reduce_only_violation_post_admitPosition changed between admit and dispatchYES if intent still applies
stp_rejectedSelf-trade prevention killed the orderNO. The caller's other order matched first
mark_price_band_violationOrder price too far from mark at dispatchNO. Re-evaluate the price and place again
evicted_under_cap_pressureAdmitted but evicted from mempool before blockYES (with backoff)
liquidation_pre_emptedAccount moved to T1+ between admit and dispatchNO. Fix margin first

Subscribe to order_updates, the real-time order-lifecycle channel for each account, and dispatch on status:

import { isChannelFrame } from '@metaflux-dex/client';

ws.onMessage((f) => {
if (!isChannelFrame(f, 'order_updates')) return;
for (const rec of f.data) {
switch (rec.status) {
case 'open': /* resting on the book; track oid */ break;
case 'filled': /* fully filled; remove from open-order set */ break;
case 'canceled': /* terminal */ break;
case 'rejected': /* commit-time error; rec.reason has the cause */
handleCommitError(rec);
break;
case 'cancel_rejected': /* the cancel itself failed; rec.reason has the cause */
handleCommitError(rec);
break;
}
}
});
await ws.subscribe({ type: 'order_updates', user: address });

A partial fill has no status of its own. A maker leg reports its filled_sz per match, with status still open while size rests. A taker's fully filled record carries status: 'filled' with filled_sz / avg_px set. See order_updates for the full field table. The table also shows a gap: modify, batchModify and engine-initiated cancels carry no per-order delta on this channel. For those, use open_orders. It is a full snapshot of the resting set, sent again on every change.

Network errors​

This is the most ambiguous class. You do not know if the server received the request, or if the action committed.

SymptomAction
TCP RST before responseReconcile: query state to find the outcome
Response timeout (you set the timeout)Same. Reconcile
Partial / truncated responseSame. Reconcile
Connection refusedThe server is unavailable. Retry with exponential backoff
DNS failureNetwork or DNS issue. Retry with exponential backoff

Reconciliation pattern​

A cloid on every order makes this cheap (see idempotency). Query open orders and look for your cloid.

For non-order actions, match on action_hash. It is deterministic and you can compute it locally:

action_hash = keccak256( action_json ‖ owner_20 ‖ nonce_be8 )
  • action_json is the raw JSON bytes of the action field, exactly as you sent them. The node hashes the bytes it received. A new serialization changes key order or whitespace, and gives a different hash. Keep the exact string that you posted.
  • owner_20 is the resolved account, not the signer. For an agent-signed order, it is the master and not the agent.
  • nonce_be8 is the nonce as 8 big-endian bytes.

The same params with a new nonce give a different hash. The synchronous /exchange admission response returns action_hash. No per-account WS event echoes it. For a committed order, correlate by cloid on order_updates / open_orders. No global feed keyed by hash answers this any more. The explorer_txs WS channel that carried the hash is removed. Its replacement, recent_transactions, does not carry a hash. Correlate by cloid, or read action_outcome for the commit-time verdict on one submitted action.

Production recipes​

Order placement with retry​

import { Client, MetaFluxApiError, type NativeOrder } from '@metaflux-dex/client';

async function placeOrderSafely(
client: Client,
address: string,
order: Omit<NativeOrder, 'cloid'>,
maxAttempts = 3,
) {
const cloid = '0x' + randomBytes(16).toString('hex');

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await client.submitOrderNative({ ...order, cloid });
} catch (e) {
if (e instanceof MetaFluxApiError) {
if (e.status === 429) {
// The 429 body carries no retry hint; back off on the refill rate.
await sleep(250 * attempt);
continue;
}
throw e; // client / signing / logical bug — propagate
}
// fetch threw before any response — unknown outcome, reconcile via cloid
const { orders } = await client.info.openOrders(address);
const placed = orders.find((o) => o.cloid === cloid);
if (placed) return placed;
continue; // bump: submitOrderNative assigns a fresh nonce each call
}
}
throw new Error('order failed after retries');
}

Idempotent cancel​

async function cancelSafely(client: Client, address: string, market: number, oid: number) {
try {
return await client.cancelOrderNative({ owner: address, market, oid });
} catch (e) {
if (e instanceof MetaFluxApiError) {
if (e.status === 404) return { alreadyDone: true };
throw e;
}
// network drop — re-query the order
const { orders } = await client.info.openOrders(address);
if (!orders.find((o) => o.oid === oid)) return { alreadyDone: true };
// it's still there — actually retry
return cancelSafely(client, address, market, oid);
}
}

WS commit reconciliation​

order_updates has no action_hash field. Correlate by cloid, and set one on every order you place:

import { isChannelFrame, type NativeOrder } from '@metaflux-dex/client';

const pendingByCloid = new Map<string, PendingAction>();

ws.onMessage((f) => {
if (!isChannelFrame(f, 'order_updates')) return;
for (const rec of f.data) {
const cloid = rec.order.cloid;
const pending = cloid ? pendingByCloid.get(cloid) : undefined;
if (!pending) continue;

if (rec.status === 'rejected' || rec.status === 'cancel_rejected') {
pending.reject(new Error(rec.reason ?? 'rejected'));
} else {
pending.resolve(rec);
}
pendingByCloid.delete(cloid!);
}
});
await ws.subscribe({ type: 'order_updates', user: address });

async function submit(order: NativeOrder) {
const cloid = order.cloid!;
const p = new Promise((resolve, reject) => pendingByCloid.set(cloid, { resolve, reject }));
await client.submitOrderNative(order);
return Promise.race([p, timeout(5000)]);
}

Edge cases​

Show edge cases
  • The gateway returns 5xx, but the action committed. This happens when the gateway's reply after admission is lost. Treat it as a network drop: reconcile through cloid or action_hash.
  • The WS feed is behind the real state. The resume buffer can evict events while you reconnect. Poll /info again on resume to anchor, then use WS for the streaming tail.
  • The same nonce is submitted twice, and one succeeds. The server enforces nonce monotonicity. The second attempt gets nonce_too_small, and you learn that the first one is running. Use this signal.
  • Delayed logical errors. A Trigger order admits today but never fires, because its trigger condition never holds. There is no error, only a resting order that stays. Reconcile your open-order set against your bot's expected set at regular intervals.

See also​

FAQ​

Show FAQ

Q: Should I treat commit-time errors as exceptions or as data? A: As data. They are normal order outcomes: cancelled because of STP, or error because of reduce-only after admission. Log them and handle them per your business logic. Do not crash on them.

Q: Is there ever a reason to ignore an admission error? A: For a purely idempotent flow, such as a cancel of an order that does not exist, you can ignore a 404. For everything else, log at INFO or above, then retry or report to the operator.

Q: How do I cap retries? A: Set a wall-clock budget per logical operation. For order placement, 5 seconds is generous. For cancels, use 2 seconds. After that, report to the operator or to your risk-watcher.