# Error responses

How REST, ConnectRPC, and the WebSocket interfaces report errors, how error categories map to HTTP status and Connect codes, and which errors are safe to retry.

Every Polyester interface reports a failure with a stable, machine-readable error code. Branch on that code. Message text such as `detail` or `message` is for logs and people, and it can change without notice.

Each interface carries the code in its own way, but the codes are shared. An order rejected over REST, ConnectRPC, or a trade WebSocket reports the same code. The full list, with each code's HTTP status and Connect code, is on [Error codes](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes). The [API reference](https://testnet.polyester.com/docs/api-docs) lists the errors of each operation.

## Error categories

The HTTP status and the Connect code tell you the category of a failure before you look at the specific code. Use the category to decide what to do next.

- **Invalid request** · HTTP 400 · `invalid_argument`\
  Fix the request. Sending it again unchanged fails the same way.
- **Request too large** · HTTP 413 · `invalid_argument`\
  Send a smaller body or a smaller batch.
- **State does not allow the request** · HTTP 400 or 412 · `failed_precondition`\
  Change the account, order, or market state first, or wait for it to change.
- **Not authenticated** · HTTP 401 · `unauthenticated`\
  Sign again or refresh the session; check the API key and the clock.
- **Not permitted** · HTTP 403 · `permission_denied`\
  Check the role, the API key's scope, and its policy. Do not retry unchanged.
- **Not found** · HTTP 404 · `not_found`\
  Check the identifier and the selected Subaccount.
- **Duplicate or reused key** · HTTP 409 · `already_exists`\
  Reconcile with the existing resource; do not create a new one blindly.
- **Concurrent change** · HTTP 409 · `aborted`\
  Read the current state, then repeat the request if it still applies.
- **Rate limit or capacity** · HTTP 429 · `resource_exhausted`\
  Wait for the retry delay, then retry. Some capacity codes say retrying does not help.
- **Client closed the request** · HTTP 499 · `canceled`\
  The client gave up before an answer; reconcile writes.
- **Internal error** · HTTP 500 · `internal`\
  Reconcile writes, then contact support if it repeats.
- **Not supported here** · HTTP 501 · `unimplemented`\
  Change the request; this combination is not offered.
- **Upstream error** · HTTP 502 · `unavailable`\
  Retry with backoff; reconcile writes first.
- **Temporarily unavailable** · HTTP 503 · `unavailable`\
  Retry with backoff and the same idempotency identity.
- **Outcome unknown** · HTTP 504 · `deadline_exceeded`\
  Read the resource before retrying. The request may have been applied.

Requests whose account or market state does not allow them answer HTTP `400`, except account, API key, and security endpoints, which answer `412`. One code breaks the pattern: `OVERLOADED`, too many order requests in flight for the account, is `resource_exhausted` on ConnectRPC but HTTP `503` on REST, with no rate-limit headers.

## REST

REST errors use an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details body with content type `application/problem+json`:

```json
{
	"type": "https://polyester.dev/problems/unknown-symbol",
	"title": "Unknown Symbol",
	"status": 404,
	"detail": "The requested trading pair symbol was not found.",
	"code": "UNKNOWN_SYMBOL",
	"symbol": "NOPE-USDT"
}
```

| Field    | Meaning                                                                                 |
| -------- | --------------------------------------------------------------------------------------- |
| `code`   | The stable error code. Branch on this                                                   |
| `status` | The HTTP status, repeated in the body                                                   |
| `title`  | A short summary of the problem                                                          |
| `detail` | A human-readable explanation of this occurrence. Do not parse it                        |
| `type`   | A URI that identifies the problem type. Treat it as an identifier; it is not a web page |

`type` follows one rule on every endpoint: `https://polyester.dev/problems/` followed by `code` in lowercase, with hyphens for underscores. `AUTH_SESSION_KIND_NOT_ALLOWED` is `https://polyester.dev/problems/auth-session-kind-not-allowed` wherever it is answered. Match on `code`; `type` is its URI form.

Some errors add fields to the body:

| Field                                  | When                                                                                                                                  |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `existingOrderId`                      | `CONFLICT_DUPLICATE_CLIENT_ORDER_ID`: the public ID of the order that holds the client order ID, when known                           |
| `rate_limit`                           | `RATE_LIMIT_EXCEEDED` (`429`) and `RATE_LIMIT_AUTHORITY_UNAVAILABLE` (`503`): the quota that refused, and `retry_after_ms` on a `429` |
| `swapId`                               | QuickSwap errors that concern an existing QuickSwap                                                                                   |
| `minDepositAmount`, `maxDepositAmount` | QuickSwap `AMOUNT_OUT_OF_RANGE`: the route's current limits, when known                                                               |
| `symbol`, `depth`                      | Order book `UNKNOWN_SYMBOL` and `INVALID_DEPTH`: the value you sent                                                                   |

The `rate_limit` object uses snake_case field names, unlike the rest of the body. How to read it is on [Rate limits](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits).

A request that fails validation answers `400` with a validation code, such as `VALIDATION_ERROR` or `INVALID_ARGUMENT`. On order endpoints the problem body also carries `violations`: each item has a `fieldPath` that names the REST request field, such as `order.baseQty` or `items[2].limitGtc.price`, a `ruleId`, and a `message`. `BATCH_TOO_LARGE` carries an `items` violation the same way. A REST order decimal with a nonzero digit beyond its scale answers the code an off-grid value answers: `QTY_STEP_SIZE` for a `baseQty` finer than the Pair's base scale, and `PRICE_TICK_SIZE` for a limit or attached-leg price with more than 9 fractional digits. A create answers it in the problem body, a modify or batch replace answers `400` `PRICE_TICK_SIZE` with the violation (such as `newPrice` or `items[1].newPrice`), a batch create rejects only that item, and a preview reports it as a rejection. Other decimals, such as `maxQuoteDebit` and `clientRefPrice`, answer `INVALID_ARGUMENT`. Zeros beyond the scale do not change the value and are accepted: a `baseQty` of `"1.500"` on a Pair with base scale 2 is read as `1.5`. See [REST getting started](https://testnet.polyester.com/docs/developer-docs/rest/getting-started).

A body over the 256 KiB size limit of an order or trigger write route answers `413` with `REQUEST_TOO_LARGE`, for API-key and session callers alike, and nothing is applied. Send a smaller body or split the batch. A body or query value that cannot be parsed answers `400` with `BAD_REQUEST`. Credentials are checked first, so an unsigned request answers `401` whatever its body.

Per-item results are different. Each failed item of a batch order response, and the `rejection` of a [preview order](https://testnet.polyester.com/docs/developer-docs/shared-concepts/preview-order), carries an `error` object with `code`, `violations`, `rateLimit`, and, for a create, `existingOrderId`. Each violation has a `fieldPath`, a `ruleId` for programmatic matching, and a `message`.

> **Not every error has a problem body**
>
> A request refused for its credentials answers a full problem body, such as `401` with `"code": "UNAUTHENTICATED"` and `"type": "https://polyester.dev/problems/unauthenticated"`. The per-IP limit on order and trigger writes answers a plain `429` with no Polyester body. A path or method the API does not serve answers `404` with `NOT_FOUND`, or a Cloudflare `403` challenge for some non-browser clients; see [Troubleshooting](https://testnet.polyester.com/docs/developer-docs/getting-started/troubleshooting). Read the HTTP status first, then parse the body according to its content type.

## ConnectRPC

A ConnectRPC error has a non-200 HTTP status and a JSON body with a Connect `code`, a `message`, and `details`. Each detail has a fully qualified protobuf `type` and its base64-encoded `value`, and can also include a `debug` JSON rendering. Generated clients and the SDKs decode details for you. This is a validation failure, with its `debug` field left out:

```json
{
	"code": "invalid_argument",
	"message": "validation error: symbol_id: must be greater than 0",
	"details": [
		{
			"type": "buf.validate.Violations",
			"value": "ClASCXVpbnQzMi5ndBoWbXVzdCBiZSBncmVhdGVyIHRoYW4gMCoRCg8IARIJc3ltYm9sX2lkGA0yGAoMCAUSBnVpbnQzMhgLCggIBBICZ3QYDQ"
		}
	]
}
```

The Polyester error code is in a domain detail. Read the detail of the service you called:

- **`orders.v1.ErrorDetail`** · Orders, triggers, order and trade reads\
  Carries `code`, `violations`, `rate_limit`, `existing_order_id`.
- **`auth.v1.AuthErrorDetail`** · Accounts, API keys, MFA, refused credentials\
  Carries `code`, `message`.
- **`auth.v1.ProfileErrorDetail`** · Profiles\
  Carries `code`, `field`, `message`.
- **`chain.withdraw.v1.ErrorDetail`** · Withdrawals\
  Carries `code`.
- **`transfer.v1.ErrorDetail`** · Internal transfers\
  Carries `code`.
- **`ledger.read.v1.ErrorDetail`** · Balances and transfer history\
  Carries `code`.
- **`marketoverview.v1.ErrorDetail`** · Market overview\
  Carries `code`.
- **`claims.v1.ErrorDetail`** · Daily claim\
  Carries `code`.
- **`swap.v1.ErrorDetail`** · Swap\
  Carries `code`, `swap_id`, `min_amount_e18`, `max_amount_e18`.
- **`swap.quickswap.v1.ErrorDetail`** · QuickSwap broker API\
  Carries `code`, `swap_id`, `min_deposit_amount`, `max_deposit_amount`.
- **`polyester.ratelimit.v1.RateLimitDetail`** · Any rate-limited method\
  Carries `reason`, `limit`, `remaining`, `retry_after_ms`, and the quota that refused.
- **`buf.validate.Violations`** · Any method, on request validation\
  Carries the failed fields and rules.

On order methods, `orders.v1.ErrorDetail.violations` lists the failed fields as `field_path`, `rule_id`, and `message`. Other validation failures carry the standard `buf.validate.Violations` detail instead.

Not every Connect error has a domain detail. Public market-data reads answer a bad symbol, or a body they cannot parse, with `code` and `message` only. `GetOrderBook` with an unknown `symbolId`, for example, answers HTTP `404`:

```json
{ "code": "not_found", "message": "The requested trading pair symbol was not found." }
```

A procedure that does not exist answers `404` with a REST problem body, code `NOT_FOUND`.

Orders, triggers, order and trade reads, and balance reads answer a ConnectRPC call refused for its credentials with a Connect error: `unauthenticated` with an `auth.v1.AuthErrorDetail` whose code names the reason, such as `AUTH_SIGNATURE_INVALID`, `AUTH_TIMESTAMP_SKEW`, or `AUTH_IP_NOT_ALLOWED`. A call with no credentials, or with a missing or expired session token, gets `AUTH_AUTHENTICATION_REQUIRED`. An account API key on a method that accepts only a session token is answered `permission_denied` (HTTP `403`) with `AUTH_SESSION_KIND_NOT_ALLOWED`. Other services can still answer a call refused before it reaches the API with a problem details body or a minimal JSON body; Connect clients then derive the code from the HTTP status, so `401` surfaces as `unauthenticated`.

## Trade WebSockets

The trade WebSockets answer a rejected command with a `CommandReject` and keep the connection open. Order codes use the same numbers as the `orders.v1.ErrorCode` enum.

## SBE

`CommandReject` carries `seq`, `correlationId`, the numeric `code`, a `detail` string, rate-limit fields for `RATE_LIMIT_EXCEEDED`, and `existingOrderId` for a duplicate client order ID. The SBE reject codes add `SEQUENCE_OUT_OF_ORDER` (`1001`) for a sequence gap on the connection.

An outcome-unknown code, or an `UNAVAILABLE` for a command that could not be sent, closes the connection with WebSocket close code `1013` after the reject. Read [Errors and rate limits](https://testnet.polyester.com/docs/developer-docs/sbe-websocket/errors-and-rate-limits) for every group of codes and what to do.

## Protobuf

`CommandReject` carries the reject code as a string in `code` and a client-safe `detail`. For rate-limit and duplicate client order ID rejects it also sets `orders.v1.ErrorDetail` in `error`, with the numeric code and any rate-limit details; other rejects leave `error` empty. An outcome-unknown code, or an `UNAVAILABLE` for a command that could not be sent, closes the connection with WebSocket close code `1013` after the reject, as on SBE. See [Protobuf trade WebSocket](https://testnet.polyester.com/docs/developer-docs/connectrpc/protobuf-trade-websocket).

## Private and realtime streams

The [private WebSocket](https://testnet.polyester.com/docs/developer-docs/connectrpc/private-websocket) at `/v1/ws/private` answers a failed request with a `RequestError` frame. It carries the request `id`, a `code` such as `FORBIDDEN` or `CURSOR_INVALID`, and a message. The connection stays open. The codes are listed under [Private WebSocket requests](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes#private-websocket-requests).

Connection-level problems close the connection with a close code:

| Close code | Meaning                                                                        | What to do                                    |
| ---------- | ------------------------------------------------------------------------------ | --------------------------------------------- |
| `1001`     | The server is draining                                                         | Reconnect, then resubscribe with your cursors |
| `4400`     | A malformed frame, an unknown operation, or a frame of the other encoding      | Fix the client                                |
| `4401`     | The connect token expired without a refresh                                    | Reconnect with a new token                    |
| `4403`     | A bad connect token, another user's token, or no `auth` frame within 5 seconds | Get a new token and authenticate in time      |
| `4408`     | The client did not read its frames fast enough                                 | Reconnect, then resubscribe with your cursors |

Back off reconnects from 0.5 seconds, doubling up to 10 seconds, with jitter. `Unavailable`, `ResyncRequired`, and `Unsubscribed` frames are not errors: they describe the state of a subscription, which recovers by itself. Token handling for the private WebSocket and the realtime WebSocket is on [WebSocket session model](https://testnet.polyester.com/docs/developer-docs/shared-concepts/websocket-session-model).

## Retries and idempotency

A failed write can mean three different things: nothing changed, the outcome is unknown, or the request changed something before it failed. The code tells you which. Many order codes say "Nothing changed" in their meaning; outcome-unknown codes end in `_OUTCOME_UNKNOWN`.

1. Classify the failure

   Do not retry an invalid request, a permission error, or a state error unchanged. Retry rate limits, temporary unavailability, and codes whose meaning says "retry shortly" or "retry later".

2. Reconcile an unknown outcome

   For `504`, `deadline_exceeded`, a `_OUTCOME_UNKNOWN` code, a lost response, or a dropped connection, read the order, trigger, or transfer before sending anything new. The request may have been applied.

3. Wait

   On a rate limit, wait at least the delay the error gives: `retry_after_ms` in the body or detail, the `Retry-After` header, or `rateLimitRetryAfterNs` on SBE. Otherwise use bounded exponential backoff with jitter.

4. Retry with the same identity

   Keep the client order ID, client trigger ID, request ID, or idempotency key, and the payload, unchanged. Sign an API-key request again with a fresh timestamp. A new identifier makes a new logical request and can create a second order or transfer.

How each identifier deduplicates a retry, and for how long, is on [Client order IDs and idempotency](https://testnet.polyester.com/docs/developer-docs/shared-concepts/client-order-ids). Quotas, retry headers, and limits that are not your quota are on [Rate limits](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits).
