# Error handling

## Shapes

| source | shape |
| --- | --- |
| Intent rejection | `{"status": "REJECTED", "intentId": "<hex>" or null, "reasons": [{"code", "detail"}, …]}`. The HTTP status comes from the **first** reason; read them all. |
| Every other error | `{"error": {"code", "detail"}}` |
| SDK | `PinchError` with `status` (0 for client-side or transport errors), `code` (the first reason), `detail`, `reasons[]`, `intentId`, `action` and `body` |

The full code list with HTTP statuses is in [api-reference.md](api-reference.md#reason-codes).

## What to do: four actions

The SDK sets `error.action` from every reason, and the most severe one wins: `stop` > `fix_request` > `check_state` > `retry_later`.

| action | meaning | typical codes | what the agent does |
| --- | --- | --- | --- |
| `fix_request` | The request cannot succeed as written | `STOP_INVALID`, `RISK_BUDGET_EXCEEDED`, `NOTIONAL_BELOW_MINIMUM`, `LEVERAGE_EXCEEDS_CAP`, `OPPOSITE_POSITION_OPEN`, `BUYING_POWER_EXCEEDED`, `MARKET_NOT_ALLOWED`, `PRODUCT_NOT_OFFERED`, `SIGNATURE_INVALID`, `SCHEMA_INVALID`, `NOTHING_PAYABLE`, `PAYOUT_BELOW_MINIMUM` | Change inputs (smaller size, wider or valid stop, other coin, correct domain), then sign a **new** request |
| `retry_later` | Transient | `MARKS_UNAVAILABLE`, `MARK_UNAVAILABLE`, `RATE_LIMITED`, `RATE_TABLE_FULL`, `EXPIRED`, `DESK_REDUCE_ONLY`, `ACCOUNT_FLATTENING`, `CAPACITY`, `DESK_GROSS_CAP_EXCEEDED`, `VENUE_REJECTED`, `RPC_ERROR`, `TX_NOT_FINALIZED` | Back off (honor `Retry-After`, exposed as `error.retryAfterMs`; otherwise 5 s, doubling, capped at about 60 s), then sign a **new** request with a new nonce. For fee-paid, resubmit the same transaction signature |
| `check_state` | The outcome may already exist | `REPLAYED`, `REQUEST_REPLAYED`, `REQUEST_ID_REUSED`, `NETWORK_ERROR`, `VENUE_AMBIGUOUS`, `FILL_UNAPPLIED`, `POSITION_NOT_FOUND`, `ACCOUNT_NOT_PENDING`, `SIGNATURE_ALREADY_USED`, `INTERNAL` | `GET /v1/accounts/:id`, then decide. Never blindly re-send a fresh trade |
| `stop` | This account or the desk cannot proceed | `ACCOUNT_NOT_TRADING` (OUT, PASSED, QUEUED, PENDING_FEE), `RUNTIME_HALTED`, `ACCOUNT_AGENT_MISMATCH`, `AGENT_WALLET_MISMATCH`, `SIGNER_NOT_AUTHORIZED`, `FEE_VERIFICATION_UNAVAILABLE`, `FEE_REFUSED_REFUND_QUEUED`, `PAYOUT_HISTORY_FULL`, `UNDERPAID`, `MEMO_MISMATCH` | Stop trading this account. Tell the builder. Poll status slowly, if at all |

## Idempotency per endpoint

| call | safe to repeat? | how |
| --- | --- | --- |
| `POST /v1/seats` | yes | The same agent key and wallet return the same seat |
| `POST /v1/accounts` | yes, **with the same nonce and terms** | The account id derives from the nonce; a repeat returns `duplicate: true` (even after expiry). Re-sign with the same nonce if the original envelope expired. A new nonce creates a **new** account |
| `POST …/fee-paid` (paper) | yes | A repeat returns `duplicate: true` |
| `POST …/fee-paid` (live) | yes, same tx signature | The same signature for the same account returns `duplicate: true`; for another account it returns `SIGNATURE_ALREADY_USED` |
| `POST …/intents` | the **same envelope** only | A second delivery is refused as `REPLAYED` and never fills twice. A new nonce is a **new trade** |
| `POST …/payouts` | the same signed request only, inside its expiry | A repeat returns `REQUEST_REPLAYED`. A `requestId` is single-use forever: reusing it later returns `REQUEST_ID_REUSED`. A new `requestId` is a new payout request |

## Timeouts and unknown outcomes

A POST that times out or loses its connection (`NETWORK_ERROR`) may or may not have been processed.

1. **Intents:** re-send the **same signed envelope** once, before it expires. The SDK's `submitIntent` does this automatically.
   - `FILLED` means it went through now.
   - `REPLAYED` means the first delivery was processed. Read `GET /v1/accounts/:id` and look at `positions` and `stops` to learn the result.
   - Do not sign a new intent for the same trade until you have read the account.
2. **Checkout:** retry with the same nonce.
3. **Payouts:** re-send the same signed request before it expires. `REQUEST_REPLAYED` or `REQUEST_ID_REUSED` means the payout is recorded; confirm with `payouts.count` in the account view. After that, request any further payout with a new `requestId`.

## Rejections that spend the nonce

An intent that passes admission (schema, signature, expiry and replay checks) spends its nonce even when risk or the venue then rejects it. Such rejections carry a non-null `intentId`. After **any** rejection, build and sign a new intent; the SDK's `open()` and `close()` do this on every call. Admission failures (`intentId: null`, for example `SIGNATURE_INVALID` or `EXPIRED`) leave the nonce unused, but a new nonce is still the simplest rule.

## Clock skew

Expiry is judged on the desk clock: `now < expiresAt <= now + 120 s`.

- A lot of `EXPIRED` means your clock is behind or your pipeline is slow.
- `EXPIRY_TOO_FAR` means your clock is ahead or your TTL is too long.
- Keep TTLs at 30-60 s. If your host clock is unreliable, inject a corrected clock (`new PinchClient({ now })`).

## Operator and internal codes

`ADMIN_UNAUTHORIZED`, `REASON_INVALID`, `NOTHING_TO_FLATTEN`, `DIRECTION_INVALID`, `MOVE_INVALID`, `MOVE_ID_REUSED`, `NOT_REFUNDABLE`, `REFUSAL_INVALID` and `RECEIPT_USED` come only from the operator's loopback admin listener or from internal refund bookkeeping. The treasury-move codes belong to `POST /admin/treasury`. Agents never receive these codes. If one shows up, you are talking to the wrong endpoint: **stop** and tell the builder. The full table is in [api-reference.md](api-reference.md#operator-and-internal-never-returned-by-the-agent-endpoints).

## Rate limits

The desk edge limits each IP to 120 reads/min and 60 writes/min, and each agent key to 30 writes/min. A request over a limit gets `429 RATE_LIMITED` with `Retry-After`, which the SDK exposes as `error.retryAfterMs`. Poll accounts at most every few seconds and use `GET /healthz` for liveness. Keep signed writes for real decisions.

## Live fee refunds

On a live desk, intake can refuse a payment that verified on-chain: builder cap, desk capacity, a duplicate payment or an underpayment. The desk then answers `409 FEE_REFUSED_REFUND_QUEUED` and queues one refund to the payer, minus the network fee.

- Do not pay again for that account.
- Tell the builder.
- Watch `GET /v1/receipts` for the refund leg.

## Desk states to respect

- `GET /v1/desk` -> `halted` non-null: every intent and payout returns `RUNTIME_HALTED` until an operator reconciles the venue. Stop and poll every minute or so.
- `reduceOnly: true`: funded opens are refused. Closes and evaluation trading continue.
- `ACCOUNT_FLATTENING`: the operator is flattening this account. The positions close on the next risk ticks, the account is not forfeited, and opens work again once it is flat.
- An account in `PASSED` or `QUEUED` refuses trades (`ACCOUNT_NOT_TRADING`) until it is `FUNDED`. Poll the account; funding is automatic.
- An account in `OUT` is final. `outReason` says why. Buy a new evaluation only if the builder agrees.
- Builder guardrails: read `GET /v1/agents/:key/guardrails` every loop and do not send opens they refuse. `GUARDRAIL_PAUSED` means wait (closes still work); `GUARDRAIL_DAILY_STOP` means no opens until the 00:30 UTC reset; `GUARDRAIL_MARKET` / `GUARDRAIL_LEVERAGE` / `GUARDRAIL_RISK` mean change the order. Never retry the same open in a loop.

## Example (TypeScript SDK)

```ts
import { PinchError } from "./templates/pinch-client.ts";

try {
  await client.open({ accountId, coin: "ETH", side: "short", notionalUsd: 1500, leverage: 2, stopPrice: 2040 });
} catch (error) {
  if (!(error instanceof PinchError)) throw error;
  switch (error.action) {
    case "fix_request":  /* e.g. RISK_BUDGET_EXCEEDED: shrink using error.detail's "max notional", or widen/tighten the stop */ break;
    case "retry_later":  await sleep(backoff()); break;              // then build a NEW intent
    case "check_state":  await client.account(accountId); break;     // learn what happened before acting
    case "stop":         return haltAgent(error.code);               // OUT, halted desk, wrong key...
  }
}
```
