Skip to main content
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

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: @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 and Order rejections). Client-side parameter validation is the one error that is not a MonacoCoreError: it is a ValidationError from @0xmonaco/types (see ValidationError).

Error Types

InvalidConfigError

Thrown when SDK is initialized or used with invalid configuration.
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).
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.

APIError

Thrown when API requests fail (HTTP/REST errors).
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
  • 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.
  • 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.
    • 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.
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/pitpass/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.
Both 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.
  • 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.

ContractError

Thrown when smart contract operations fail.
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. In batchCreate / batchReplace a per-item rejection is not thrown at all — it arrives as results[].error.code in a 200 response.
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.
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.

Retry Logic

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

Basic Retry Pattern

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.

Advanced Retry with Rate Limiting

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

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.
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.
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.
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.
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.
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.
Suggestions the SDK produces: Example: Using suggestions in UI

Complete Error Handling Example


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 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