> ## Documentation Index
> Fetch the complete documentation index at: https://docs.0xmonaco.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Error Handling

> Comprehensive error handling guide for Monaco Protocol SDK

The Monaco SDK provides robust error handling with typed error classes, retry logic, and helpful suggestions for common issues.

## Error Classes

All SDK errors extend the base `MonacoCoreError` class and include standardized properties for handling errors gracefully.

### Base Error Properties

```typescript theme={null}
import { MonacoCoreError } from "@0xmonaco/core";

try {
  await sdk.vault.deposit(assetId, amount);
} catch (error) {
  if (error instanceof MonacoCoreError) {
    console.error('Code:', error.code);           // Error code (e.g., "INVALID_CONFIG")
    console.error('Message:', error.message);     // Human-readable error message
    console.error('Suggestion:', error.suggestion); // Helpful fix suggestion
    console.error('Retryable:', error.retryable); // Whether retry might succeed
    console.error('Timestamp:', error.timestamp); // When error occurred
  }
}
```

**Common Properties:**

* `code`: MonacoErrorCode - Standardized error code
* `message`: string - Human-readable error description
* `suggestion?`: string - Actionable fix suggestion
* `retryable`: boolean - Whether retrying the operation might succeed
* `timestamp`: number - Unix timestamp when error occurred
* `cause?`: unknown - Original error that caused this error

***

## Error Codes

Every `MonacoCoreError` carries a `code` literal that identifies its class. Four are in use, one per exported class:

| `code` | Class |
| - | - |
| `INVALID_CONFIG` | `InvalidConfigError` |
| `INVALID_STATE` | `InvalidStateError` |
| `API_ERROR` | `APIError` |
| `CONTRACT_ERROR` | `ContractError` |

`@0xmonaco/core` exports exactly five error classes — `MonacoCoreError`, `InvalidConfigError`, `InvalidStateError`, `APIError`, and `ContractError` — and nothing else from its error module. There is no `ERROR_CODES` constant and no `OrderError`, `NetworkError`, `InvalidAuthError`, or `RateLimitError` to import: a network failure, an expired session, a rate limit, and a rejected order all surface as an `APIError`, distinguished by `statusCode` and the response envelope (see [APIError](#apierror) and [Order rejections](#order-rejections)). Client-side parameter validation is the one error that is not a `MonacoCoreError`: it is a `ValidationError` from `@0xmonaco/types` (see [ValidationError](#validationerror)).

***

## Error Types

### InvalidConfigError

Thrown when SDK is initialized or used with invalid configuration.

```typescript theme={null}
import { InvalidConfigError } from "@0xmonaco/core";

try {
  await sdk.vault.deposit(assetId, amount);
} catch (error) {
  if (error instanceof InvalidConfigError) {
    console.error('Configuration issue:', error.field);
    console.error('Suggestion:', error.suggestion);

    // Common cases:
    if (error.field === 'account') {
      // Wallet has no account connected
      alert('Please connect your wallet');
    } else if (error.field === 'seiRpcUrl') {
      // Invalid or missing RPC URL
      console.error('Provide valid Sei RPC URL');
    }
  }
}
```

**Common Scenarios:**

* Missing required configuration (network, RPC URL, etc.)
* Invalid configuration values
* Wallet client without account (v0.5.6+)

***

### InvalidStateError

Thrown when a method is called before its prerequisite is in place. Today only `sdk.getAccountAddress()` throws it: `Wallet client not set` when the SDK was built without a `walletClient`, and `No account available` when the wallet client has no account. Calling an authenticated API method before `login()` is **not** an `InvalidStateError` — it is an `APIError` with `statusCode` `401` (see below).

```typescript theme={null}
import { InvalidStateError } from "@0xmonaco/core";

try {
  const address = sdk.getAccountAddress();
} catch (error) {
  if (error instanceof InvalidStateError) {
    console.error('State issue:', error.message);
    console.error('Suggestion:', error.suggestion);
    // "Check that prerequisites are met before calling this method."

    if (error.message === 'Wallet client not set') {
      console.error('SDK initialized without wallet client');
    } else if (error.message === 'No account available') {
      console.error('Wallet not connected or account unavailable');
    }
  }
}
```

**Additional properties:** `currentState?` and `expectedState?` — when the SDK supplies an expected state, `suggestion` names it. There is no `field` on this class; that property belongs to `InvalidConfigError`.

***

### ValidationError

Thrown **before any request is sent** when parameters fail the SDK's client-side schema — a non-UUID `tradingPairId`, a non-positive `quantity` or `price`, a `LIMIT` without `price`, a `MARKET` with `timeInForce`, more than 100 batch items, or invalid pagination on a read. It is exported by `@0xmonaco/types` (not `@0xmonaco/core`), extends the plain `Error` — so it has no `code`, `suggestion`, or `retryable` — and is never worth retrying: fix the parameters.

```typescript theme={null}
import { ValidationError } from "@0xmonaco/types";

try {
  await sdk.trading.placeLimitOrder(pairId, 'BUY', '-10', '100');
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(error.message);      // "Validation failed:\n  - quantity: ..."
    console.error(error.getErrors());  // { quantity: "..." } — field path → message, for forms
    console.error(error.issues);       // raw Zod issues
  }
}
```

***

### APIError

Thrown when API requests fail (HTTP/REST errors).

`APIError.serverCode` is the server's stable error code from the REST error envelope's `code` field (e.g. `ACCOUNT_DEACTIVATED`, `POST_ONLY_WOULD_CROSS`), or `undefined` when the response carried none. Branch on it rather than on the message text. It is distinct from `error.code`, which is always `API_ERROR` for this class.

```typescript theme={null}
import { APIError } from "@0xmonaco/core";

try {
  // A read: safe to auto-retry on a retryable error. For order create/replace,
  // see "Retrying order mutations safely" below — reconcile, don't blind-retry.
  await sdk.trading.getPaginatedOrders({ status: 'SUBMITTED' });
} catch (error) {
  if (error instanceof APIError) {
    console.error('API Error:', error.statusCode);
    console.error('Endpoint:', error.endpoint);

    // Check if retryable (safe here because the wrapped call is a read)
    if (error.retryable) {
      // retryAfter is in SECONDS (from the Retry-After header); default to 5s.
      console.log('Can retry after:', error.retryAfter ?? 5, 'seconds');
      // Implement retry logic (multiply by 1000 for setTimeout, which is ms)
    }

    // Handle specific status codes
    switch (error.statusCode) {
      case 400:
        console.error('Bad request:', error.message);
        break;
      case 401:
        console.error('Unauthorized - please login');
        await sdk.login(clientId);
        break;
      case 429:
        console.error('Rate limited - wait before retrying');
        break;
      case 500:
        console.error('Server error - retry may help');
        break;
    }
  }
}
```

The `retryable` classification below is safe to auto-retry for **reads** (`getPaginatedOrders`, `getOrder`, balances, market data) and for **idempotent mutations** — cancelling an order or closing a position converges on the same end state however many times it runs, so a resubmit cannot double-apply.

**Retryable Status Codes (reads and idempotent mutations):**

* No status code (network error) - retryable
* 429 (Rate Limit) - retryable after `retryAfter` seconds
* 500+ (Server Error) - retryable
* 409 (Conflict) - retryable for reads; for a single order create/replace treat it as ambiguous (see below) — **except** `CLIENT_ORDER_ID_CONFLICT` and `IDEMPOTENCY_KEY_CONFLICT`, which definitively reject the current submit

**Non-Retryable Status Codes:**

* 400 (Bad Request) - fix request parameters
* 401 (Unauthorized) - re-authenticate. A `401` for clock skew (`Request timestamp outside allowed skew`) is handled for you: the SDK reads the server's time from each response's `Date` header, signs with the corrected time once the device clock is 5 seconds or more off, and retries a signed request once after a `401` that corrected the clock. A WebSocket `Authenticate` handshake rejected for skew reconnects and re-signs, up to 3 times, with whatever the clock has learned since — so it recovers once a REST response has calibrated the clock; an `AUTH_FAILED` frame carries no server time, so a socket-only client never calibrated by REST still ends in `onReauthenticationRequired`. A missing or malformed `Date` header (or a response the browser may have served from cache) leaves the offset unchanged, so the SDK falls back to the device clock. Browser clients need an API gateway that exposes the `Date` header over CORS; Monaco's does.
* 403 (Forbidden) - permission issue. A `serverCode` of `ACCOUNT_DEACTIVATED` means the account is deactivated: any signed request can return it, and signing in again does not help
* 404 (Not Found) - resource doesn't exist

**Order mutations — retain keys before retrying creates:**

Single create and each batch-create item accept an `idempotencyKey`. Reusing the same retained key with the same normalized payload returns the original committed result for 24 hours after acceptance. The SDK generates a key when omitted, but a second call generates a new one, so supply and persist your own key before sending when recovery must cross calls or process restarts. A raw unkeyed create, a retry with a new key, or a retry after 24 hours can submit a second order. A resting `LIMIT` can leave duplicate exposure on the book; an order that never rests — `MARKET`, `IOC`/`FOK`, or a `LIMIT` that fills on arrival — can execute again instead.

**Replace is different, and safer.** `replaceOrder` / `batchReplace` name the *original* order id, and the matching engine revalidates that original — in memory and on the book — at sequence time. Once one replacement succeeds the original is gone, so a retry is rejected with `ORDER_NOT_FOUND` instead of placing a second order. Duplicate execution is therefore not this endpoint's failure mode. It is still not safe to fire blindly: `ORDER_NOT_FOUND` is ambiguous on its own (the original may instead have filled or been cancelled), so reconcile to find the replacement's **new order id** rather than to rule out a double.

* **Definitively rejected — safe to resubmit:** `401` (auth-expired) and `429` (rate-limited) are rejected *before the order reaches the book*, so it was never applied — re-authenticate or back off, then resubmit. A create rejected with the maintenance `OPERATIONS_BLOCKED` `503` (see below) was likewise blocked, not applied.
* **Ambiguous create or batch item with a retained key — replay safely:** reuse the exact key and normalized payload within 24 hours. Do not change the key or payload. A different payload returns `IDEMPOTENCY_KEY_CONFLICT`; after 24 hours the same key may place a new order.
* **Ambiguous unkeyed/expired create or replace — reconcile first, never blind-retry:** after a network error, timeout, or `5xx`/`409` with no order in the body, the first attempt may already be live or filled. Reconcile on the order stream you subscribed to *before* sending; treat `getPaginatedOrders` / `getOrder` as a weak cross-check only, since they are replica-backed and an order rejected *before* acceptance is never written to them at all. See [Retrying order mutations safely](#retrying-order-mutations-safely).
* **Definitively rejected key reuse:** `IDEMPOTENCY_KEY_CONFLICT` means that key was already accepted with different normalized order details. The current submit was not applied; recover the original payload instead of changing the key.
* **Retryable — keyed submission storage:** two structured codes arrive as HTTP `503` / gRPC `UNAVAILABLE` on a single create, or as a per-item `results[].error.code` in a batch. They differ in whether an order may already exist, so do not treat them alike.

  * `ORDER_SUBMISSION_CAPACITY_EXCEEDED` refuses a **new** key because retained submission receipts are at capacity while responses are protected by pending durability or persistence. The refusal neither accepts nor reserves the key, so **nothing was applied**. Capacity frees as protected receipts finish acknowledgement and persistence and become evictable — usually seconds, not the retention window — so retry the same key with bounded backoff rather than waiting for anything to expire. The same code refuses a new key when your own share is full — each user and application can hold at most 10,000 receipts (32 MiB of receipt payload) that are not yet evictable — while every other owner keeps placing orders; retries of an existing key and orders without a key are unaffected.
  * `ORDER_SUBMISSION_UNAVAILABLE` means the original response for a key you already used cannot currently be retrieved, or keyed admission is busy. **Do not read it as a rejection:** a submission under that key may already exist and may already have executed. It never authorizes duplicate execution, so retry the same key with the same normalized payload and let the replay tell you the outcome.

  Never mint a fresh key for an order you already submitted under one — the original may still commit, and a new key is a second independently admissible order. Existing retained receipts keep replaying while storage is full, and a previously accepted key still expires 24 hours after its original acceptance.
* **Definitively rejected, not ambiguous (only when you set a `clientOrderId`):** a `409` whose `error.code` is `CLIENT_ORDER_ID_CONFLICT` means another of your **resting** orders already holds that `clientOrderId`, so the current single create/replace was rejected — it was not applied, and this is the one `409` on a single create/replace that is *not* ambiguous. **If a create's response was lost**, that conflict additionally confirms the earlier attempt landed *and is still resting* — read the order named in the message. But its **absence proves nothing**, so do not resubmit in order to probe for it: the handle is claimed only while an order rests, so an attempt that filled on arrival, was `IOC`/`FOK`, or was a `MARKET` order raises no conflict, and the resubmission can execute again. Reconcile on the order stream first. Outside that retry (an accidental duplicate, or a replace colliding with an unrelated resting order) the code proves only that the current submit was rejected, not that any earlier one landed. In `batchCreate` / `batchReplace` the same conflict is a per-item `results[].error.code` in a `200` response, not a transport `409`. See [Client Order IDs](/developers/trade#client-order-ids).

**Maintenance mode and matching-engine availability:**

* During a scheduled maintenance window, mutating operations (new and replace orders, TP/SL, margin changes, collateral transfers, deposits, withdrawals, faucet mint, delegated-agent config, and reward transfers) are rejected with `503` and a stable `OPERATIONS_BLOCKED` error code (gRPC `UNAVAILABLE` with `x-maintenance-blocked` response metadata). Sign-in, order cancels, position closes, risk simulations, and delegated-session creation stay available. Retry once maintenance ends.
* `POST /api/v1/positions/{positionId}/close`, `POST /api/v1/positions/{positionId}/tp-sl`, `POST /api/v1/tradercodes/rewards/transfer`, and the unauthenticated `GET /api/v1/orderbook/{tradingPairId}` return a retryable `503` (instead of `500`) when the matching engine is momentarily unreachable or restarting; non-transient engine faults still return `500`.
* Automatic parent margin account creation and cross risk bucket provisioning return retryable `503` / gRPC `UNAVAILABLE` when the sequencer refuses provisioning because its lane is full. This can surface while first funding, placing a margin order or simulating one; back off before retrying. A provisioning overload is an admission refusal, while a transport timeout may leave the larger operation's outcome unknown.

These availability errors surface as an `APIError` with `retryable: true` — they fall under the `500+` retryable rule above.

**Authentication availability (a different dependency):**

* `POST /api/v1/auth/verify` returns a retryable `503` that has nothing to do with the matching engine. Three causes are contract-signature (EIP-1271) verification of an eligible address that could not be completed — the chain did not answer, did not answer within the deadline, or the server was at its concurrent-verification capacity. A fourth is unrelated to contract wallets entirely: verify reads private-beta feature-flag state first, and a read that cannot be served returns the same `503` for any address, before any signature work. So do not attribute a `503` here to the chain, and do not look for the matching engine behind it.
* It is **not** a verdict on the signature: a rejected signature is `401`, and so is a wallet contract that rejects by reverting.
* **Every authenticated call** can return a retryable `503` (`SERVICE_UNAVAILABLE`, message `Authentication temporarily unavailable; retry`; gRPC `UNAVAILABLE` with the same message) when the server cannot complete the session check because a database or other dependency is unavailable. The session is still valid — keep it and retry with bounded exponential backoff and jitter, capping the attempts so clients do not retry in lockstep during an outage; do not log in again. `401` (gRPC `UNAUTHENTICATED`) is reserved for credentials that were actually rejected: bad signature, timestamp outside the skew window, session not found, expired or revoked, or an inactive delegation. Like any auth failure, it is rejected before an order reaches the book.
* This one surfaces as an `APIError` with `retryable: true` on the REST/TypeScript path. The gRPC surface (`AuthService.Verify`) is not a TypeScript `APIError` at all — it returns the status `UNAVAILABLE`, which a gRPC caller branches on directly.

**The one exception to the 5xx rule:** `sdk.buildercodes.claimBuildercodeRewards` clears `retryable` on every failure whose outcome it cannot decide, deliberately against the rule above. That is **any** status at or above `500` — not only `503` — and also a failure carrying **no status at all**, because the response was never seen and the request may well have been served. A `4xx` is a decided outcome and keeps its usual classification. The claim is admitted to the sequencer before its reply is sent and carries no idempotency key, so the outcome is **unknown** rather than rejected and a blind retry can claim twice; a balance read does not settle it, because an admitted claim may still be queued. Within that ambiguous set, the two tagged cases are the ones still safe to retry, because both are refused before admission: a maintenance block (`error: "OPERATIONS_BLOCKED"`) and a sequencer overload (`code: "OVERLOADED"`). This says nothing about the `4xx` responses, which keep the SDK's ordinary classification — the claim's documented `409`, like any `429`, is still flagged retryable. Do not narrow your own handling to `503` — a `500` or a connection reset on this call is exactly as ambiguous. See [claiming rewards](/sdk/typescript/buildercodes).

***

### ContractError

Thrown when smart contract operations fail.

```typescript theme={null}
import { ContractError } from "@0xmonaco/core";

try {
  await sdk.vault.deposit(assetId, amount);
} catch (error) {
  if (error instanceof ContractError) {
    console.error('Contract error:', error.revertReason);
    console.error('Contract:', error.contractAddress);
    console.error('Transaction:', error.transactionHash);

    // Common issues:
    if (error.revertReason?.includes('insufficient allowance')) {
      console.log('Need to approve tokens first');
      await sdk.vault.approve(assetId, amount);
    }
  }
}
```

**Common Scenarios:**

* Insufficient token allowance
* Insufficient gas
* Contract execution reverted
* Invalid contract parameters

***

### Order rejections

There is no `OrderError` class. A rejected order create, replace, or cancel is an `APIError` whose `responseBody` carries the REST error envelope: `error` (the API error type, e.g. `OPERATIONS_BLOCKED`), `message`, an optional `code` (the stable matching-engine code, e.g. `POST_ONLY_WOULD_CROSS`, `ORDER_NOT_FOUND`, `CLIENT_ORDER_ID_CONFLICT`, `IDEMPOTENCY_KEY_CONFLICT`, `SELF_TRADE_NOT_ALLOWED`), `statusCode`, and optional `details`. `error.serverCode` returns that `code` directly. In `batchCreate` / `batchReplace` a per-item rejection is not thrown at all — it arrives as `results[].error.code` in a `200` response.

```typescript theme={null}
import { APIError } from "@0xmonaco/core";

try {
  await sdk.trading.cancelOrder(orderId);
} catch (error) {
  if (error instanceof APIError) {
    const body = (error.responseBody ?? {}) as { error?: string; code?: string; message?: string };
    console.error('Order rejected:', error.statusCode, body.code ?? body.error, body.message);

    if (error.statusCode === 404 || body.code === 'ORDER_NOT_FOUND') {
      console.log('Order already filled or cancelled');
    } else if (error.statusCode === 400 && body.message?.startsWith('Insufficient balance')) {
      console.log('Insufficient balance for this order');
    }
  }
}
```

**Self-trade prevention adds two codes — and only one self-cross outcome is an error at all.** `SELF_TRADE_NOT_ALLOWED` fires exactly when a `FOK` order carrying `selfTradePreventionMode: "CANCEL_BOTH"` finds one of your own resting orders in its fill path: that placement is rejected and nothing fills or cancels — a `400` with `code: "SELF_TRADE_NOT_ALLOWED"` on a single create, a per-item `results[].error.code` in a `200` batch response. Every other self-cross outcome is a **cancellation, not an error**: the call succeeds and the affected orders terminate as `CANCELLED` with `terminalReason: "SELF_TRADE_PREVENTION"` (the WebSocket cancellation event also names the other side in `stpCounterpartyOrderId`), so watch order status rather than catching an exception. `INVALID_SELF_TRADE_PREVENTION_MODE` is the per-item `batchCreate` / `batchReplace` validation code for an unrecognized `selfTradePreventionMode` value; the same mistake on a single create or replace is a plain `400` whose message names the valid tokens, with no structured `code`. Mode semantics: [Self-Trade Prevention](/trading-mechanics/self-trade-prevention).

<Note>
  **Wallet shortfall vs. margin shortfall.** A genuine wallet shortfall — not enough
  funds to place the order — surfaces as a `400` / invalid-input error whose message
  begins `Insufficient balance`. A **margin** shortfall on a `MARGIN` order is a
  different condition and now carries the risk engine's own wording (same `400` /
  invalid-input type, only the message text differs):

  * `insufficient initial margin: required=..., available=...` — the order needs more
    initial margin than the risk bucket can supply.
  * `insufficient free collateral: requested=..., available=...` — not enough free
    collateral in the margin account for the requested amount.

  Match on these two messages (not `Insufficient balance`) to detect a margin
  shortfall. Here the wallet is not short — the margin is — so depositing more funds
  does not resolve it. For `insufficient initial margin`, reduce the position size or
  add collateral to the risk bucket; note that *lowering* leverage raises the required
  initial margin (`notional / leverage`), so it makes this rejection worse, not better.
  For `insufficient free collateral`, free up or add collateral in the margin account,
  or reduce the requested amount.
</Note>

***

## Retry Logic

SDK errors include a `retryable` property to indicate if retrying might succeed.

### Basic Retry Pattern

<Warning>
  Wrap reads and convergent mutations (cancel, close-position) freely. Wrap an order
  create or batch item only when the closure reuses one explicit `idempotencyKey` and
  unchanged payload within 24 hours. Never wrap a replace or let each create attempt
  generate a new key. See [Retrying order mutations safely](#retrying-order-mutations-safely).
</Warning>

```typescript theme={null}
async function withRetry<T>(
  operation: () => Promise<T>,
  maxRetries = 3,
  baseDelay = 1000
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      if (error instanceof MonacoCoreError && error.retryable && attempt < maxRetries) {
        // `retryAfter` is in SECONDS — multiply by 1000 for setTimeout (ms).
        // Use `!== undefined` so a server-sent 0 (retry now) is honored rather
        // than falling through. Otherwise back off exponentially with jitter.
        const delayMs = error instanceof APIError && error.retryAfter !== undefined
          ? error.retryAfter * 1000
          : baseDelay * 2 ** (attempt - 1) + Math.random() * 1000;

        console.log(`Retry ${attempt}/${maxRetries} after ${Math.round(delayMs)}ms`);
        await new Promise(resolve => setTimeout(resolve, delayMs));
      } else {
        throw error; // Not retryable or max retries exceeded
      }
    }
  }
  throw new Error('Max retries exceeded');
}

// Usage — wrap a read (or an idempotent mutation), never order create/replace:
const openOrders = await withRetry(() =>
  sdk.trading.getPaginatedOrders({ status: 'SUBMITTED', tradingPairId: pairId })
);
```

### Advanced Retry with Rate Limiting

<Warning>
  Same rule as above — wrap reads or idempotent mutations only. Do not wrap
  `placeLimitOrder`, `placeMarketOrder`, or `replaceOrder`. See
  [Retrying order mutations safely](#retrying-order-mutations-safely).
</Warning>

```typescript theme={null}
import { APIError } from "@0xmonaco/core";

async function retryWithRateLimit<T>(
  operation: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      return await operation();
    } catch (error) {
      if (error instanceof APIError) {
        if (error.statusCode === 429 && attempt < maxRetries) {
          // Rate limited - respect the Retry-After header. `retryAfter` is in
          // SECONDS; convert to ms for setTimeout. Default to 5s when absent.
          const delayMs = (error.retryAfter ?? 5) * 1000;
          console.log(`Rate limited. Waiting ${delayMs}ms...`);
          await new Promise(resolve => setTimeout(resolve, delayMs));
          continue;
        } else if (error.retryable && attempt < maxRetries) {
          // Other retryable error - exponential backoff with jitter.
          const delayMs = 1000 * 2 ** (attempt - 1) + Math.random() * 1000;
          await new Promise(resolve => setTimeout(resolve, delayMs));
          continue;
        }
      }
      throw error;
    }
  }
  throw new Error('Max retries exceeded');
}
```

***

## Retrying order mutations safely

Single create and every batch-create item support an idempotency key. The SDK sends
each request once and generates a key when omitted; because another SDK call generates
a new key, cross-call recovery requires an explicit key persisted before the first send.
Replay the same normalized payload with that key within 24 hours to receive the original
committed result. Never reuse it with changed details, and never retry an unresolved
submission after the window expires. Replace has no idempotency key. For an unkeyed or
expired create, or a replace, the safe pattern remains **reconcile, then decide** — and
when reconciliation is inconclusive, **do not resubmit**.

<Note>
  **A `2xx` does not mean the order executed.** An `IOC` or `FOK` that cannot fill — or a
  `MARKET` order whose apparent liquidity is invalidated by maker-risk revalidation after
  acceptance — is accepted and then rejected by the match: the call returns successfully with
  `matchResult.status` `REJECTED`, and the stream emits `OrderPlaced` then `OrderRejected`.
  Read `matchResult.status`; do not infer the outcome from the absence of an exception.
</Note>

<Warning>
  `clientOrderId` does not make a retry safe. Only a **resting** order holds one, so an
  order that filled on arrival, or was a `MARKET`, `IOC` or `FOK`, releases the handle and a
  resubmission under the same value is accepted as a new order and can execute again. A
  `CLIENT_ORDER_ID_CONFLICT` is meaningful when it fires, but its absence proves nothing.
  See [Client Order IDs](/developers/trade#client-order-ids).
</Warning>

**Subscribe before you send.** The order stream is the only surface pushed straight from
the matching engine, and it carries your `clientOrderId` even on orders that never rest.
It is not replayable: a slow-client disconnect (close code `1013`) gaps the stream and
there is no wire sequence number to backfill from, so a subscription opened *after* the
timeout cannot recover the acknowledgement you missed. Subscribe first — that is what lets
an event reach you at all. But note what this does *not* buy you: the fan-out is
non-persistent Core NATS, and the service can re-establish an ended upstream subscription
without closing your socket, so an event can be missed while your connection looks healthy.
Subscribing makes a positive answer possible; it never makes silence meaningful.

**Rank your evidence.** In descending order of strength:

1. **The order stream.** An `OrderPlaced`, fill, cancellation, or `OrderRejected` carrying
   your `clientOrderId` is proof the order was accepted. Silence is **not** evidence of the
   opposite, and no observable condition makes it so: the fan-out is non-persistent Core
   NATS, and the WebSocket service can re-establish an ended upstream subscription without
   closing your socket, so events can be missed while your connection still reads as
   healthy. Subscribe before you send — that is what lets an event reach you at all — but
   never convert its absence into a negative answer.
2. **Position or balance movement.** Attributable movement is positive proof something
   executed, and it is independent of the order read model, so it survives replica lag.
   The converse does **not** hold: unchanged state is inconclusive, not proof nothing
   executed. `getPositions` only consults the live view for an `OPEN` status filter and
   otherwise reads the same replica, and concurrent fills elsewhere can net a delta to
   zero. Treat movement as evidence *for*, never absence as evidence *against*.
3. **`getOrder` / `getPaginatedOrders`.** Weakest. Written asynchronously and served from
   a replica, so a *missing* row is not evidence the order did not land — most likely
   right after a timeout, since a timeout usually means the system is busy. An order rejected *before* acceptance is never written at all; one rejected *after* acceptance is persisted with status `REJECTED`. A row that *is* present is real; an absent row is
   inconclusive.

**When the evidence is inconclusive, hold.** The two mistakes are not symmetric: a
wrongful resubmit doubles live exposure, while a missed quote is recoverable by quoting
again. Prefer the missed quote.

Registering a handler is not the same as being subscribed: `sdk.ws.userOrders` only
adds a local handler — the SDK sends the `Subscribe` frame when the socket is open and
exposes no per-subscription acknowledgement, and the constructor connects
asynchronously. A subscription registered in the same breath as the place can
therefore miss the acknowledgement without any disconnect. Establish the subscription
**at startup** and confirm the connection is up (`sdk.ws.isConnected()`, or your
`onStatusChange` hook). Doing so is what lets a matching event reach you — it does not
make silence meaningful. Even a long-lived subscription that has been receiving other
events cannot turn silence into a negative answer, because the upstream fan-out can be
re-established without your socket changing state.

```typescript theme={null}
import { APIError, type MonacoSDK } from "@0xmonaco/core";

// At startup — once, not per order. Register a durable handler and route events
// to whoever is waiting on a clientOrderId.
const pending = new Map<string, (orderId: string | null) => void>();
sdk.ws.userOrders((event) => {
  const coid = event.data?.clientOrderId;
  if (coid) pending.get(coid)?.(event.data.orderId ?? null);
});
// Wait for the connection before trading: registration alone does not prove the
// server-side subscription is active.

// Ambiguous = you never got a definitive response: network error, timeout, or a
// 5xx/409 with no order in the body. 401/429 are definitive rejections — auth is
// validated, and rate limits are applied, before the order reaches the book, so it
// was never placed and those may be resubmitted directly.
// The envelope carries TWO discriminators and they are different fields: `code` is the
// matching-engine code (CLIENT_ORDER_ID_CONFLICT), `error` is the API error type
// (OPERATIONS_BLOCKED). Read whichever the case needs.
function envelope(error: APIError): { code?: string; error?: string } {
  const body = error.responseBody;
  return typeof body === "object" && body !== null ? (body as { code?: string; error?: string }) : {};
}

function isAmbiguous(error: unknown): boolean {
  if (!(error instanceof APIError)) return true; // no HTTP status → assume ambiguous
  if (error.statusCode === 401 || error.statusCode === 429) return false;
  // A 409 is ambiguous EXCEPT when the engine names a deterministic conflict:
  // either code definitively rejects this submit, so there is nothing to reconcile.
  if (error.statusCode === 409) {
    return !["CLIENT_ORDER_ID_CONFLICT", "IDEMPOTENCY_KEY_CONFLICT"].includes(envelope(error).code ?? "");
  }
  // Maintenance is a deliberate pre-book rejection, not an ambiguous failure: the
  // order was blocked, never applied. Retry once the window ends.
  if (error.statusCode === 503 && envelope(error).error === "OPERATIONS_BLOCKED") return false;
  return error.statusCode === undefined || error.statusCode >= 500;
}

async function placeOrderReconciling(
  sdk: MonacoSDK,
  pairId: string,
  side: "BUY" | "SELL",
  quantity: string,
  price: string,
): Promise<string | null> {
  // Cheap precondition, NOT a guarantee: `isConnected()` reads local socket state
  // only (`readyState === OPEN`). The SDK sends Authenticate and Subscribe on open
  // and reports "connected" immediately, while the server activates the
  // subscription asynchronously — so this can pass before `orders` is live. It is
  // worth checking because a closed socket means no positive answer is possible at
  // all; it does not establish that one would reach you.
  if (!sdk.ws.isConnected()) {
    throw new Error("order stream not connected — refusing to place without it");
  }

  const clientOrderId = `quote-${crypto.randomUUID()}`;

  // Arm the matcher BEFORE sending, on the subscription established at startup,
  // and keep it armed for as long as the request is in flight. The stream is not
  // replayable, so a subscription opened after the failure cannot recover the
  // acknowledgement. Note there is no timer here: the SDK's fetch has no deadline
  // of its own, and a bounded wait started now could expire while the request is
  // still pending — disarming the matcher just before the event arrives.
  const landed = new Promise<string | null>((resolve) => {
    pending.set(clientOrderId, resolve);
  });

  try {
    const res = await sdk.trading.placeLimitOrder(pairId, side, quantity, price, {
      clientOrderId,
    });
    return res.orderId;
  } catch (error) {
    if (!isAmbiguous(error)) throw error; // definitively rejected — never applied

    // Ambiguous. Only NOW start the bounded wait, so the window measures how long
    // we wait for the acknowledgement rather than overlapping the request itself.
    const orderId = await Promise.race([
      landed,
      new Promise<null>((resolve) => setTimeout(() => resolve(null), 10_000)),
    ]);
    if (orderId) return orderId; // it landed — do NOT resubmit

    // No event. This is NOT evidence the order failed — the upstream fan-out can
    // be re-established without this socket changing state, so no observable
    // condition makes silence conclusive. Movement works one way only: an attributable position/balance change means
    // the order DID execute, so it blocks placing again — but unchanged state is
    // inconclusive and never clears you to place. If anything is unclear, hold and
    // surface the ambiguity rather than risking a double.
    return null;
  } finally {
    pending.delete(clientOrderId);
  }
}
```

Reconcile for `replaceOrder` with a different question in mind. A retry cannot open a
duplicate — the original is revalidated at sequence time and a second attempt returns
`ORDER_NOT_FOUND` — so what you are resolving is *which* state you are in, not whether you
doubled. A successful replace assigns a **new** order id and marks the original
`CANCELLED` with `terminalReason: "REPLACED"`, so watch the stream for the replacement
carrying your `clientOrderId`. `getOrder(originalOrderId)` is the weaker fallback and is
asymmetric: `terminalReason: "REPLACED"` is positive evidence your replacement landed and
you need its new id, but an original that still reads live is **inconclusive** — order
persistence is asynchronous, so that row can predate a replacement the engine has already
applied. Do not treat it as proof the replace failed.

### Batch create: inspect per item, clean up failed legs

`batchCreate` returns **partial success** — a failed leg does not stop the others.
When the call *resolves*, inspect each item: a succeeded leg carries an `orderId`, while
an item that did not return success carries an `error` and an empty `orderId`. Never
resubmit the whole batch. A deterministic validation or business rejection can be
corrected and resubmitted by itself, but `SERVICE_UNAVAILABLE` has an **unknown outcome**:
reconcile that leg first and retry only with its original explicit `idempotencyKey`.

```typescript theme={null}
const legs = [
  { tradingPairId: pairId, orderType: 'LIMIT', side: 'BUY', price: '35000.00', quantity: '0.5', idempotencyKey: crypto.randomUUID() },
  { tradingPairId: pairId, orderType: 'LIMIT', side: 'SELL', price: '36000.00', quantity: '0.3', idempotencyKey: crypto.randomUUID() },
];
const batch = await sdk.trading.batchCreate(legs);

console.log(`${batch.totalSucceeded}/${batch.totalRequested} created`);

batch.results.forEach((result, index) => {
  if (!result.error) return;
  if (result.error.code === 'SERVICE_UNAVAILABLE') {
    // Unknown outcome: reconcile first. If a retry is still needed, submit only
    // legs[index] so it carries the exact same explicit idempotencyKey.
    console.warn('Leg outcome unknown:', legs[index].idempotencyKey);
    return;
  }
  console.warn('Leg rejected:', result.error.code, result.error.message);
});

// If one side landed and the other failed you are one-sided: cancel the live legs
// (cancel is idempotent — safe to retry) or place the missing side before quoting.
```

If the whole `batchCreate` call instead **throws** (network error, timeout, or a
`5xx` with no body), an unknown subset of the legs may already be live. Replay each
unresolved leg with its exact explicit `idempotencyKey` and unchanged payload within
24 hours. If a leg has no retained key or its window expired, reconcile it like a
legacy single order: use the order stream subscribed before sending, then position or
balance movement, with `getPaginatedOrders` only as a weak replica-backed cross-check.
If that evidence is inconclusive, hold rather than resubmit.

***

## Error Suggestions

Every `MonacoCoreError` carries a `suggestion` the SDK derives from the failure — the configuration field, the HTTP status and message, or the contract revert reason. `ValidationError` has none; read its `message` or `getErrors()` instead.

```typescript theme={null}
try {
  const sdk = new MonacoSDK({ network: 'invalid-network', seiRpcUrl });
} catch (error) {
  if (error instanceof InvalidConfigError) {
    console.error('Error:', error.message);
    console.error('Suggestion:', error.suggestion);
    // Output:
    // Error: "network must be one of: mainnet, development, staging, local. Got: invalid-network"
    // Suggestion: "Check the 'network' configuration parameter. Valid networks are: 'mainnet', 'development', 'staging', 'local'."
  }
}
```

**Suggestions the SDK produces:**

| Error Type | Scenario | Suggestion |
| - | - | - |
| **InvalidConfigError** | Unknown `network` | "Check the 'network' configuration parameter. Valid networks are: 'mainnet', 'development', 'staging', 'local'." |
| | Missing or malformed `seiRpcUrl` | "Check the 'seiRpcUrl' configuration parameter. Use '[https://evm-rpc-testnet.sei-apis.com](https://evm-rpc-testnet.sei-apis.com)' for testnet or '[https://evm-rpc.sei-apis.com](https://evm-rpc.sei-apis.com)' for mainnet." |
| | No wallet client, or its chain does not match the network (`field: 'walletClient'`) | "Check the 'walletClient' configuration parameter. Ensure you're creating the wallet client with a valid account and chain configuration." |
| | Wallet client has no account (`field: 'account'`) | "Check the 'account' configuration parameter. Refer to the SDK documentation for correct configuration format." |
| **InvalidStateError** | `getAccountAddress()` without a wallet client or account | "Check that prerequisites are met before calling this method." |
| **ValidationError** (`@0xmonaco/types`) | Parameters fail the client schema — negative quantity, non-UUID id, more than 100 batch items | No `suggestion`; `message` is `Validation failed:` followed by one `field: reason` line per issue, and `getErrors()` returns them keyed by field |
| **ContractError** | Deposit before approval (`revertReason: 'APPROVAL_REQUIRED'`) | No `suggestion`; the message itself says to call `approve()` first for the asset |
| | Chain revert containing `insufficient allowance` | "Token allowance is insufficient. Call sdk.vault.approve() to increase the token allowance for the vault contract." |
| | Chain revert containing `insufficient balance` | "Wallet balance is insufficient for this transaction. Ensure you have enough tokens and native currency for gas fees." |
| | Revert mentioning `nonce` | "Transaction nonce error. This may be due to a pending transaction. Wait for pending transactions to complete or reset your account nonce." |
| | Revert mentioning `gas` | "Transaction ran out of gas. Try increasing the gas limit for this transaction." |
| | Withdrawal execution failed with no revert reason | "Smart contract execution failed. Check transaction parameters and try again." |
| **APIError (no `statusCode`)** | Network failure or timeout (`Network request failed for …`) | "Network request failed. Check your internet connection and try again." — `retryable: true` |
| **APIError (4xx)** | 400 whose message contains `Insufficient balance` | "Insufficient balance. Deposit more funds using sdk.vault.deposit() or reduce the order size." |
| | 400 whose message contains `invalid` | "Invalid request parameters. Check the API documentation for correct parameter format and values." |
| | Any other 400, including the margin shortfalls above | "Bad request. Verify that all required parameters are provided and correctly formatted." |
| | 401 before `login()` (`Session keypair not set. Authenticate (login) first.`) or `refreshAuth()` without a session | "Authentication required. Call sdk.login(clientId) to authenticate before making this request." |
| | 401 whose message contains `expired` | "Access token has expired. Call sdk.refreshAuth() to get a new token, or call sdk.login() to re-authenticate." |
| | 403 Forbidden | "Access denied. Ensure your application has the required permissions and your client ID is valid." |
| | 403 with `serverCode` `ACCOUNT_DEACTIVATED` | Same generic 403 suggestion — branch on `serverCode` instead and report that the account is deactivated |
| | 404 Not Found | "Resource not found. Check that the ID or identifier is correct and the resource exists." |
| | 409 Conflict | "Resource conflict. The resource may have been modified. Refresh and try again." — `retryable: true`, but apply the `409` rules under [APIError](#apierror) before resubmitting an order |
| | 429 Too Many Requests | "Rate limit exceeded. Wait a moment before retrying. Consider implementing exponential backoff for retries." — sleep `error.retryAfter` seconds (parsed from `Retry-After`). When `retryAfter` is `undefined` on a `429`, the block came from the edge, which sets no `Retry-After`: read the wait from `error.responseBody.details.retryAfter` (seconds) or fall back to a bounded, jittered backoff — never retry immediately, since the block lasts minutes to an hour. `APIError` exposes no other response headers, so the `x-ratelimit-*` budget headers order endpoints return are readable only from a raw HTTP client; authenticated reads draw a separate per-account budget with the same `retryAfter` and no headers, so move polling to the WebSocket streams; see [Rate Limits](/developers/rate-limits) |
| **APIError (5xx)** | 500–599 | "Server error occurred. This is likely a temporary issue. Try again in a few moments." — `retryable: true` |

**Example: Using suggestions in UI**

```typescript theme={null}
try {
  await sdk.vault.deposit(assetId, amount);
} catch (error) {
  if (error instanceof MonacoCoreError && error.suggestion) {
    // Show suggestion to user
    toast.error(`${error.message}\n\nSuggestion: ${error.suggestion}`);
  } else {
    toast.error(error.message);
  }
}
```

***

## Complete Error Handling Example

```typescript theme={null}
import {
  MonacoSDK,
  MonacoCoreError,
  InvalidConfigError,
  InvalidStateError,
  APIError,
  ContractError,
} from "@0xmonaco/core";
import { ValidationError } from "@0xmonaco/types";

async function placeOrderWithHandling(
  sdk: MonacoSDK,
  pairId: string,
  side: 'BUY' | 'SELL',
  quantity: string,
  price: string,
  attempt = 1
) {
  // Bound the retries so persistent ambiguity/auth/rate-limit can't recurse forever.
  if (attempt > 5) throw new Error('Max order-placement attempts exceeded');

  try {
    const order = await sdk.trading.placeLimitOrder(pairId, side, quantity, price);
    console.log('Order placed:', order.orderId);
    return order;

  } catch (error) {
    // Handle specific error types
    if (error instanceof InvalidConfigError) {
      console.error('Configuration error:', error.message);
      if (error.suggestion) {
        console.log('Fix:', error.suggestion);
      }
      throw error; // Can't recover from config errors

    } else if (error instanceof ValidationError) {
      // Rejected client-side before any request was sent — fix the parameters, do not retry.
      console.error('Invalid order parameters:', error.getErrors());
      throw error;

    } else if (error instanceof InvalidStateError) {
      console.error('State error:', error.message);
      throw error;

    } else if (error instanceof APIError) {
      console.error('API error:', error.statusCode, error.message);

      // 401 and 429 are DEFINITIVE rejections — the order never reached the book,
      // so re-authenticating / backing off and resubmitting cannot double-submit.
      if (error.statusCode === 401) {
        console.log('Re-authenticating...');
        await sdk.login('your-client-id');
        return placeOrderWithHandling(sdk, pairId, side, quantity, price, attempt + 1);
      } else if (error.statusCode === 429 && error.retryable) {
        // `retryAfter` is in SECONDS — convert to ms for setTimeout.
        const delayMs = (error.retryAfter ?? 5) * 1000;
        console.log(`Rate limited. Waiting ${delayMs}ms...`);
        await new Promise(resolve => setTimeout(resolve, delayMs));
        return placeOrderWithHandling(sdk, pairId, side, quantity, price, attempt + 1);
      } else if (error.retryable) {
        // AMBIGUOUS (network / timeout / 5xx / 409 with no order): the create MAY
        // already be live or filled. There is no safe automatic resubmit here.
        //
        // The order list CANNOT clear this: it is replica-backed, so a missing row
        // is not evidence the order did not land, and an order rejected
        // before acceptance is never written to it at all (one rejected after
        // acceptance is persisted with status REJECTED). Resubmitting on "no match" is the double-execution
        // path — see "Retrying order mutations safely" above.
        //
        // Resolve it on the order stream you subscribed to BEFORE sending, keyed on
        // a clientOrderId. A matching event settles it; silence does NOT, and no
        // observable condition makes silence meaningful. Absent positive evidence
        // the outcome stays unresolved: hold and escalate rather than placing again.
        console.warn('Ambiguous create — reconcile on the order stream; not resubmitting.');
        throw error;
      }
      throw error;

    } else if (error instanceof ContractError) {
      console.error('Contract error:', error.revertReason);
      if (error.revertReason?.includes('insufficient allowance')) {
        console.log('Approving tokens...');
        // Handle approval and retry
      }
      throw error;

    } else if (error instanceof MonacoCoreError) {
      // Generic MonacoCoreError handling
      console.error('SDK error:', error.code, error.message);
      if (error.suggestion) {
        console.log('Suggestion:', error.suggestion);
      }
      throw error;

    } else {
      // Unexpected error
      console.error('Unexpected error:', error);
      throw error;
    }
  }
}
```

***

## Best Practices

1. **Always check error types** - Use `instanceof` to handle different error classes
2. **Use retry logic for retryable errors** - Check `error.retryable` property
3. **Respect rate limits** - `error.retryAfter` is in **seconds**; multiply by 1000 before passing it to `setTimeout` (which expects milliseconds)
4. **Add jitter to backoff** - When no `retryAfter` is provided, use exponential backoff with a small random jitter so concurrent clients don't retry in lockstep
5. **Show user-friendly messages** - Use `error.suggestion` for helpful guidance
6. **Log for debugging** - Include `error.code`, `timestamp`, and `cause`
7. **Handle auth errors** - Re-authenticate on 401 errors and retry
8. **Don't retry non-retryable errors** - Avoid wasting time on errors that won't succeed
9. **Retain create idempotency keys before sending** - Replay a single create or batch item only with the same explicit key and normalized payload within 24 hours. For a replace, unkeyed create, or expired key after an ambiguous outcome, [reconcile first](#retrying-order-mutations-safely) on the order WebSocket stream subscribed before sending. Only a matching event resolves it: silence is never evidence the order did not land, and replica-backed order reads are only a weak cross-check. **When the evidence is inconclusive, hold rather than resubmit.** Cancels and position closes are safe to retry.

***

## See Also

* [TypeScript SDK](/sdk/typescript/intro) - SDK setup and configuration
* [Authentication](/sdk/typescript/auth) - Login and auth error handling
* [Order Management](/developers/trade) - Order placement error handling


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.