Skip to main content

Error handling

tip

Stable.

A decision tree for production clients. Every error code, and the status it answers with, is in errors; this page tells you what to do about each class.

Three failure layers

LayerWhen firesHow surfaced
AdmissionAt /exchange requestHTTP status + body
CommitAt block commit, post-admissionorder_updates / fills WS push, or visible in user_fills / open_orders
NetworkAnywhereTCP error, timeout, partial response

Each layer has different semantics. Confusing them is the most common production bug.

Decision tree

Layer 1 — admission errors

The request was parsed, but rejected 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 it can be reworded in any release — a handler keyed off the sentence breaks silently, and it breaks in the direction where a known rejection reads as an unknown one.

And 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; 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_FOUNDDon't retry; check the resource
Ours, not yoursINTERNAL, UNAVAILABLERetry with backoff, then report

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

The classes below (ClientBugError, AuthError, …) are an example taxonomy for a hand-rolled client working directly against fetch. The TypeScript SDK does not export them — it throws one class, MetaFluxApiError, carrying the error code, and you branch on that 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);
}
}

Layer 2 — commit errors

The action was admitted (202) but failed at commit. You learn about it only via the event stream.

ErrorCauseRetry?
reduce_only_violation_post_admitPosition changed between admit and dispatchYES if intent still applies
stp_rejectedSelf-trade prevention killed the orderNO — caller's other order matched first
mark_price_band_violationOrder price too far from mark at dispatchNO — re-evaluate price and re-place
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 live, per-account order-lifecycle channel — 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 does not get its own status: a maker leg reports its per-match filled_sz with status still open while size rests, and a taker's fully filled record carries status: 'filled' with filled_sz / avg_px set. See order_updates for the full field table — including the gap on modify / batchModify / engine-initiated cancels, which carry no per-order delta on this channel (use open_orders instead, a full resting-set snapshot re-emitted on every change).

Layer 3 — network errors

The most ambiguous class. Did the server receive the request? Did the action commit?

SymptomAction
TCP RST before responseReconcile: query state to determine outcome
Response timeout (you set the timeout)Same — reconcile
Partial / truncated responseSame — reconcile
Connection refusedServer side is unavailable; retry with exponential backoff
DNS failureNetworking / DNS issue; retry with exponential backoff

Reconciliation pattern

The cloid-on-orders pattern (see idempotency) makes this cheap: query open orders, see if your cloid is there.

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. Re-serializing changes key order or whitespace and gives a different hash. Keep the exact string you posted.
  • owner_20 is the resolved account, not the signer. For an agent-signed order that is the master, not the agent.
  • nonce_be8 is the nonce as 8 big-endian bytes.

The same params with a new nonce give a different hash. action_hash is returned synchronously in the /exchange admission response — it is not echoed on any per-account WS event. For a committed order, correlate by cloid on order_updates / open_orders instead. No global, hash-keyed feed answers this any more. The explorer_txs WS channel that carried the hash is removed, and 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');
}

Cancel with idempotent safety

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 instead (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
  • Gateway returns 5xx but the action actually committed. Can happen if the gateway's post-admit reply was lost. Treat like a network drop: reconcile via cloid/action_hash.
  • WS feed is behind real state. Resume buffer may have evicted the events while you were reconnecting. Re-poll /info on resume to anchor; switch to WS for the live tail.
  • Same nonce submitted twice — once succeeds. Server enforces nonce monotonicity; the second attempt sees nonce_too_small and you learn the first one is live. Use this signal.
  • Time-bomb logical errors. A Trigger order that admits today but never fires because its trigger condition never holds. No error; just a resting order that hangs around. Periodically reconcile your open-order set against your bot's expected set.

See also

FAQ

Show FAQ

Q: Should I treat commit-time errors as exceptions or as data? A: Data. They're regular order outcomes — cancelled because of STP, error because of post-admit reduce-only. Log + handle per business logic; don't crash on them.

Q: Is there ever a reason to ignore an admission error? A: For pure idempotent flows (cancel of a non-existent order), 404 is fine to swallow. For everything else, log at INFO+ and either retry or surface to the operator.

Q: How do I cap retries? A: Wall-clock budget per logical operation. For order placement, 5 seconds is generous; for cancels, 2 seconds. Beyond that, surface to the operator or your risk-watcher.