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

# Error Handling

> Comprehensive error handling patterns for resilient trading bots.

# Error Handling

Every API response uses standard HTTP status codes with structured JSON error bodies.

***

## Error Response Format

By default, the `/v1/*` surface returns the **Polymarket-shape**
single-field envelope — one `error` key holding a human-readable
description:

```json theme={null}
{"error": "Account balance $12.50 insufficient for order cost $25.00"}
```

| Field   | Type   | Description                                                                                                                                                                                           |
| ------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `error` | string | Human-readable description. For PM-shape parity, the machine-readable short code is carried out-of-band in the `X-Polysim-Code` response header (e.g. `INSUFFICIENT_BALANCE`, `RATE_LIMIT_EXCEEDED`). |

For stable error handling, **branch on the `X-Polysim-Code` header**, not
on the body text — the body holds the prose, the header holds the code.
The `X-Request-Id` header echoes the request id for log correlation.

**`402 UPGRADE_REQUIRED` is the one carve-out** to the PM-shape default:
its body adds `feature_key` and `upgrade_url` alongside `error` so SDKs
can render an upsell flow without an extra round-trip. Every other
status code (401, 403, 404, 429, 5xx, …) sticks with the single-field
shape and exposes the machine code via `X-Polysim-Code`.

```json theme={null}
// 402 UPGRADE_REQUIRED — feature_key + upgrade_url at the root
{
  "error": "UPGRADE_REQUIRED",
  "feature_key": "wallets.sandbox_baseline",
  "upgrade_url": "/pricing"
}
```

For the runtime allowlist gate (`ACCESS_RESTRICTED` — an already-issued
key whose account isn't on the API v1 allowlist, or is flagged / under
review), the body is PM-shape and the code is in the header:

```json theme={null}
// 403 ACCESS_RESTRICTED (not on the allowlist / flagged account)
// + headers: X-Polysim-Code: ACCESS_RESTRICTED
{"error": "API access restricted. Contact support to request access."}
```

The closed-beta **key-issuance** gate uses the same shape: `POST /v1/keys`
and `POST /v1/keys/bootstrap` return `403 CLOSED_BETA` (the default for
every non-admitted caller, including paying Pro / Pro+; the
`API_PRO_COMING_SOON` variant appears only once self-serve issuance is
enabled) with the machine code in the `X-Polysim-Code` header and the
human message in the body's `error` field. The `feature_key` /
`upgrade_url` hints are body fields **only on 402** responses, not these
403s. See [Closed Beta Errors](#closed-beta-errors) below.

<Tip>
  **Verbose body opt-in.** Send `X-Polysim-Verbose: true` on any
  request to get the legacy multi-field shape:

  ```json theme={null}
  {"error": "INVALID_KEY", "message": "Invalid API key",
   "details": null, "request_id": "a1b2c3d4-..."}
  ```

  Useful when writing or debugging an SDK; PM-shape is the default so
  Polymarket-CLOB SDK ports work without translation.
</Tip>

***

## HTTP Status Codes

| Code  | Meaning                              | Retry?  | Action                        |
| ----- | ------------------------------------ | ------- | ----------------------------- |
| `200` | Success                              | —       | Process response              |
| `400` | Bad request                          | No      | Fix request payload           |
| `401` | Invalid or missing API key           | No      | Check `X-API-Key` header      |
| `403` | Insufficient permissions             | No      | Check key scopes              |
| `404` | Resource not found                   | No      | Verify market ID or order ID  |
| `409` | Conflict (duplicate idempotency key) | No      | Use original response         |
| `422` | Validation error                     | No      | Fix input fields              |
| `429` | Rate limited                         | **Yes** | Wait for `Retry-After` header |
| `500` | Internal server error                | **Yes** | Retry with backoff            |
| `502` | Upstream error                       | **Yes** | Retry with backoff            |
| `503` | Service unavailable                  | **Yes** | Retry with backoff            |

***

## Common Error Codes

### Trading Errors

This table is the **canonical trading error-code reference** — other
trading pages link here rather than restating the codes.

| Error Code                     | HTTP | Description                                                                                                                                                                                                                                                              |
| ------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `INSUFFICIENT_BALANCE`         | 400  | Not enough funds for order                                                                                                                                                                                                                                               |
| `INSUFFICIENT_POSITION`        | 400  | Sell rejected — your position size for the chosen `token_id` is smaller than the requested `quantity`. PolySimulator does not support naked shorts: every SELL must be backed by an existing position. See **No naked shorts** below for the two-sided-quote workaround. |
| `INVALID_QUANTITY`             | 400  | Non-positive `quantity` rejected by the execution engine. (A schema-level out-of-range value is caught earlier by Pydantic as `422 VALIDATION_FAILED` — see HTTP Status Codes above.)                                                                                    |
| `PRICE_REQUIRED`               | 400  | Market order submitted without the required `price` (worst-price limit).                                                                                                                                                                                                 |
| `FOK_ORDER_NOT_FILLED_ERROR`   | 400  | The order could not fill entirely at or beyond your worst-price limit (BUY: best price above your cap; SELL: best price below your floor). This is the worst-price rejection for market / FOK orders — there is **no** `409 LIMIT_PRICE_NOT_MET` code.                   |
| `INVALID_ORDER_PAYLOAD`        | 400  | Body shape invalid (PM-shape `/v1/order`): bad maker/taker amounts, missing `tokenId`, or unsupported `side`                                                                                                                                                             |
| `INVALID_ORDER_MIN_TICK_SIZE`  | 400  | Limit price doesn't conform to the market's tick size (0.1 / 0.01 / 0.001 / 0.0001). Round to a multiple of `GET /v1/tick-size/{token_id}`. Mirrors Polymarket's behaviour exactly.                                                                                      |
| `UNSUPPORTED_ORDER_TYPE`       | 400  | `order_type=IOC` on `POST /v1/clob/order` is rejected (use `GTC` to rest or `FOK` for immediate-or-fail). Code is in the `X-Polysim-Code` header.                                                                                                                        |
| `MARKET_NOT_FOUND`             | 404  | Unknown `market_id` (or unknown `tokenId` on PM-shape `/v1/order`). Verify with `GET /v1/markets-by-token/{token_id}`.                                                                                                                                                   |
| `MARKET_CLOSED`                | 400  | Market is resolved or inactive                                                                                                                                                                                                                                           |
| `ORDER_NOT_FOUND`              | 404  | Unknown `order_id`                                                                                                                                                                                                                                                       |
| `ORDER_NOT_CANCELLABLE`        | 400  | The order is already `FILLED`, `CANCELLED`, or `EXPIRED` and can't be cancelled.                                                                                                                                                                                         |
| `DUPLICATE_CLIENT_ORDER_ID`    | 409  | A new order reused a `client_order_id` already bound to a different order.                                                                                                                                                                                               |
| `IDEMPOTENCY_KEY_REUSE`        | 409  | The same `Idempotency-Key` was replayed with a **different** request body. (An identical replay instead returns the original order — see Idempotency below.)                                                                                                             |
| `IDEMPOTENCY_CONFLICT_PENDING` | 409  | The same `Idempotency-Key` is still being processed; carries `Retry-After: 1`.                                                                                                                                                                                           |
| `EXECUTION_ERROR`              | 500  | Server-side error during fill — report with `request_id`                                                                                                                                                                                                                 |

<Note>
  There is no `409 LIMIT_PRICE_NOT_MET`, `409 IDEMPOTENCY_CONFLICT`,
  `CANNOT_CANCEL`, or `HTTP_409` trading code — those names appeared in
  earlier drafts but are not emitted by the engine. Use the codes above.
</Note>

### Order status values

`OrderResponse.status` (and the `status` filter on `GET /v1/orders`) use
the PolySimulator-native enum — note the **double-L** `CANCELLED`:

| Status      | Meaning                                                                                                                  |
| ----------- | ------------------------------------------------------------------------------------------------------------------------ |
| `PENDING`   | Limit order resting on the book, not yet filled.                                                                         |
| `FILLED`    | Order matched and executed.                                                                                              |
| `CANCELLED` | Order cancelled (by you, a FOK/FAK non-fill, or the auto-cancel safeguard). Native spelling is **double-L** `CANCELLED`. |
| `EXPIRED`   | Order expired (e.g. market resolved while resting).                                                                      |
| `REJECTED`  | Per-entry batch failure (see [Batch Orders](/trading/batch-orders)).                                                     |
| `ERROR`     | Per-entry batch internal error (batch only).                                                                             |

SDKs ported from Polymarket read the PM-shape `ORDER_STATUS_*` enum from
`GET /v1/data/orders` instead — where the cancelled member is the
**single-L** `ORDER_STATUS_CANCELED` (PM's exact spelling). The native
`GET /v1/orders` path uses double-L `CANCELLED`; the PM-shape
`GET /v1/data/orders` path uses single-L `ORDER_STATUS_CANCELED`. See
[CLOB Compatibility](/concepts/clob-compatibility).

#### No naked shorts — `INSUFFICIENT_POSITION` explained

PolySimulator (and Polymarket itself) requires every SELL order to be backed by an
existing position in that exact `token_id`. There is no margin, no borrow, no
synthetic short. If you try to sell shares you don't hold, the order is rejected
with `400 INSUFFICIENT_POSITION` (header `X-Polysim-Code: INSUFFICIENT_POSITION`).

For binary markets (every `/markets/{id}` with two outcomes), the standard
market-maker idiom is a **two-sided buy** rather than a buy + a short:

```python theme={null}
# Wrong — naked short on the "Down" side:
sell_down = post("/v1/order", json={"token_id": down_tok, "side": "SELL", "quantity": 100, "price": 0.51})
# → 400 INSUFFICIENT_POSITION (you don't hold any Down shares)

# Right — buy both sides instead. Up + Down ≈ 1.00, so total notional is similar
# to a two-sided quote, and you keep the spread on whichever side fills first:
buy_up   = post("/v1/order", json={"token_id": up_tok,   "side": "BUY", "quantity": 100, "price": 0.49})
buy_down = post("/v1/order", json={"token_id": down_tok, "side": "BUY", "quantity": 100, "price": 0.49})
```

After a buy fills you accumulate position; subsequent SELLs against that position
are accepted up to the held quantity. Use `GET /v1/account/positions` to check
your inventory per `token_id` before submitting a SELL.

### Authentication Errors

| Error Code                | HTTP | Description                                                                                                                                                                                                                                                 |
| ------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MISSING_AUTH`            | 401  | No `Authorization: Bearer …` or `X-API-Key` on a route that accepts either                                                                                                                                                                                  |
| `MISSING_API_KEY`         | 401  | `X-API-Key` (or legacy `POLY_API_KEY`) missing on a key-only route                                                                                                                                                                                          |
| `INVALID_KEY`             | 401  | Key doesn't exist or is revoked                                                                                                                                                                                                                             |
| `INVALID_TOKEN`           | 401  | Bearer JWT is malformed, missing `sub`, or fails signature verification                                                                                                                                                                                     |
| `KEY_EXPIRED`             | 401  | API key has expired                                                                                                                                                                                                                                         |
| `KEY_DEACTIVATED`         | 401  | Key was administratively disabled                                                                                                                                                                                                                           |
| `TOKEN_EXPIRED`           | 401  | Bearer JWT past its `exp` claim                                                                                                                                                                                                                             |
| `INSUFFICIENT_PERMISSION` | 403  | Key missing the required permission (e.g. `trade` for order endpoints)                                                                                                                                                                                      |
| `ACCESS_RESTRICTED`       | 403  | An already-issued key (or Bearer JWT) is not on the API v1 access list (or the account is flagged / under review). Returned on authenticated `/v1/*` requests. Body is PM-shape; check the `X-Polysim-Code` header.                                         |
| `CLOSED_BETA`             | 403  | Key issuance refused on `POST /v1/keys` / `POST /v1/keys/bootstrap`. The default for every non-admitted caller while the beta is closed, including paying Pro / Pro+. Machine code in the `X-Polysim-Code` header; the body's `error` is the human message. |
| `API_PRO_COMING_SOON`     | 403  | Conditional variant of the issuance gate for a paying Pro / Pro+ caller without a cohort grant — reached only once self-serve issuance is enabled (until then they also get `CLOSED_BETA`). Machine code in the `X-Polysim-Code` header.                    |
| `UPGRADE_REQUIRED`        | 402  | Pro-tier cap reached (sandbox count, etc.). **Only** 402 carries `feature_key` + `upgrade_url` at the root of the response body.                                                                                                                            |

### Rate Limit Errors

The backend emits **two distinct 429 codes** (in the `X-Polysim-Code`
header). Both are retryable and both carry `Retry-After` — branch on
either, or simply treat any 429 as a back-off signal:

| Error Code            | HTTP | Description                                                                                                                                                                                             |
| --------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RATE_LIMIT_EXCEEDED` | 429  | The per-tier in-process concurrency cap (all `/v1/*` paths) or the IP / per-key request-rate limiter. Retry after `Retry-After`.                                                                        |
| `RATE_LIMITED`        | 429  | The cross-worker **trade-concurrency** limiter on the three trade-write paths (`POST /v1/orders`, `/v1/orders/batch`, `/v1/clob/order`). Body also carries `retry_after_ms`. Retry after `Retry-After`. |

<Tip>
  A bot that branches **only** on `RATE_LIMIT_EXCEEDED` will miss the
  `RATE_LIMITED` 429s from the trade-write paths (and vice-versa). The
  robust pattern is to back off on `resp.status_code == 429` regardless
  of which code is in the header.
</Tip>

The per-tier limits (authoritative source: `GET /v1/keys/tiers`):

| Tier       | Req/sec | Req/min | WS conns | Max batch |
| ---------- | :-----: | :-----: | :------: | :-------: |
| Free       |    2    |   120   |     1    |     1     |
| Pro        |    10   |   600   |     3    |     5     |
| Pro+       |    30   |  1,800  |    10    |     10    |
| Enterprise |   100   |  6,000  |    50    |     25    |

Closed-beta cohort keys run at the enterprise tier until the cutoff
(2026-08-31), then auto-downgrade to free + read-only. If a static value
here ever disagrees with `GET /v1/keys/tiers`, the endpoint wins.

### Closed Beta Errors

The API is in an ongoing closed beta. Key **issuance** is cohort-gated:

| Error Code            | HTTP | Description                                                                                                                                                                                                                                                                                                                                                               |
| --------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `CLOSED_BETA`         | 403  | `POST /v1/keys` / `POST /v1/keys/bootstrap` refused. The default for every non-admitted caller while the beta is closed, including paying Pro / Pro+. Machine code in the `X-Polysim-Code` header; the body's `error` is the human message. **Not retryable** — apply via the waitlist at [https://polysimulator.com/api-trading](https://polysimulator.com/api-trading). |
| `API_PRO_COMING_SOON` | 403  | Same endpoints, conditional variant for a paying Pro / Pro+ caller without a cohort grant — reached only once self-serve issuance is enabled (until then they also get `CLOSED_BETA`). Machine code in the `X-Polysim-Code` header.                                                                                                                                       |
| `ACCESS_RESTRICTED`   | 403  | Separate runtime gate: an already-issued key (or Bearer JWT) is not on the API v1 access list (or is flagged / under review). Returned on authenticated `/v1/*` requests. Check the `X-Polysim-Code` header. **Not retryable**.                                                                                                                                           |
| `COHORT_FULL`         | 409  | Beta cohort issuance is at capacity. Detail: `current_active`, `cap`, `requested`.                                                                                                                                                                                                                                                                                        |

Beta-issued keys carry an `X-API-Beta-Cutoff` response header on every request after the cutoff date — SDKs can pivot to read-only mode without an extra round-trip.

```python theme={null}
# Handle the closed-beta key-issuance gate cleanly. The issuance codes
# (CLOSED_BETA / API_PRO_COMING_SOON) are in the `X-Polysim-Code` header;
# the body's `error` field holds the human message.
resp = requests.post(f"{BASE_URL}/v1/keys", headers={"X-API-Key": KEY}, json={"name": "bot"})
if resp.status_code == 403:
    code = resp.headers.get("X-Polysim-Code")
    if code == "CLOSED_BETA":
        # Not in an admitted cohort yet — apply via the waitlist.
        print("Closed beta — join the waitlist at https://polysimulator.com/api-trading")
    elif code == "API_PRO_COMING_SOON":
        # Paying Pro / Pro+ without a cohort grant — rolling out in cohorts.
        print("Pro API access is rolling out — see /account/billing")
```

***

## Retry Strategy

<CodeGroup>
  ```python Python — Exponential Backoff theme={null}
  import time
  import requests

  def api_call_with_retry(method, url, max_retries=3, **kwargs):
      """Make API call with exponential backoff on retryable errors."""
      for attempt in range(max_retries + 1):
          try:
              resp = requests.request(method, url, **kwargs)

              if resp.status_code == 429:
                  # Rate limited — use server-provided wait time
                  wait = int(resp.headers.get("Retry-After", 2 ** attempt))
                  print(f"Rate limited, waiting {wait}s...")
                  time.sleep(wait)
                  continue

              if resp.status_code >= 500:
                  # Server error — retry with backoff
                  wait = 2 ** attempt
                  print(f"Server error {resp.status_code}, retry in {wait}s...")
                  time.sleep(wait)
                  continue

              # Success or client error (no retry)
              resp.raise_for_status()
              return resp.json()

          except requests.exceptions.ConnectionError:
              wait = 2 ** attempt
              print(f"Connection error, retry in {wait}s...")
              time.sleep(wait)

      raise Exception(f"Max retries ({max_retries}) exceeded for {url}")
  ```

  ```javascript JavaScript — Exponential Backoff theme={null}
  async function apiCallWithRetry(method, url, options = {}, maxRetries = 3) {
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        const resp = await fetch(url, { method, ...options });

        if (resp.status === 429) {
          const wait = parseInt(resp.headers.get("Retry-After") || 2 ** attempt);
          console.log(`Rate limited, waiting ${wait}s...`);
          await new Promise(r => setTimeout(r, wait * 1000));
          continue;
        }

        if (resp.status >= 500) {
          const wait = 2 ** attempt;
          console.log(`Server error ${resp.status}, retry in ${wait}s...`);
          await new Promise(r => setTimeout(r, wait * 1000));
          continue;
        }

        if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
        return await resp.json();

      } catch (err) {
        if (err.message.includes("fetch failed") && attempt < maxRetries) {
          const wait = 2 ** attempt;
          console.log(`Connection error, retry in ${wait}s...`);
          await new Promise(r => setTimeout(r, wait * 1000));
          continue;
        }
        throw err;
      }
    }
    throw new Error(`Max retries (${maxRetries}) exceeded for ${url}`);
  }
  ```
</CodeGroup>

***

## WebSocket Error Handling

WebSocket connections use custom close codes:

| Close Code | Meaning                     | Action                   |
| ---------- | --------------------------- | ------------------------ |
| `1000`     | Normal close                | Reconnect if desired     |
| `1001`     | Server going away           | Reconnect after 1s       |
| `4001`     | Authentication failed       | Get new token, reconnect |
| `4002`     | Subscription limit exceeded | Reduce subscriptions     |

```python theme={null}
import asyncio
import aiohttp

async def resilient_ws(url, token, market_ids):
    """WebSocket connection with automatic reconnection."""
    backoff = 1
    while True:
        try:
            async with aiohttp.ClientSession() as session:
                async with session.ws_connect(f"{url}?token={token}") as ws:
                    backoff = 1  # Reset on successful connect

                    await ws.send_json({
                        "action": "subscribe",
                        "markets": market_ids,
                    })

                    async for msg in ws:
                        if msg.type == aiohttp.WSMsgType.TEXT:
                            handle_message(msg.data)
                        elif msg.type == aiohttp.WSMsgType.CLOSED:
                            break
                        elif msg.type == aiohttp.WSMsgType.ERROR:
                            break

        except Exception as e:
            print(f"WS error: {e}")

        wait = min(backoff, 30)
        print(f"Reconnecting in {wait}s...")
        await asyncio.sleep(wait)
        backoff *= 2
```

***

## Best Practices

<CardGroup cols={2}>
  <Card title="Always check status codes" icon="circle-check">
    Never assume a 2xx response. Parse the status code and handle each category appropriately.
  </Card>

  <Card title="Use Retry-After header" icon="clock">
    On 429 responses, the `Retry-After` header tells you exactly how long to wait. Don't guess.
  </Card>

  <Card title="Don't retry 4xx errors" icon="xmark">
    Client errors (400-422) indicate a problem with your request. Fix the payload instead of retrying.
  </Card>

  <Card title="Log error details" icon="file-lines">
    Always log the full error response body for debugging — the `details` field often contains actionable info.
  </Card>
</CardGroup>
