Errors
The failure modes that do not look like failures — 200-with-ok-false, CORS that is really a 502, and silent read degradation.
The three shapes of failure
| Shape | Where | How you notice |
|---|---|---|
| HTTP 4xx/5xx | BFF routes | Normal — ApiError carries status and body |
HTTP 200 + ok: false | Engine proxy | Only if you check the body |
| Silent degradation | Reads with no DB, live orders against an old engine | Empty or stale data, no error at all |
Engine rejections are 200
POST /v1/orders answers HTTP 200 with {"ok": false, "code": "…"} for a rejected order.
Code that branches on res.ok alone reports a rejected order as filled.
const res = await apiPost('/v1/orders', body, headers);
if (!res.ok) {
// this is the rejection path, and it arrived as a 200
toast.error(rejectionMessage(res.code));
return;
}Common codes:
| Code | Meaning | Usual cause |
|---|---|---|
invalid_size | Size off the grid | Not an exact multiple of 10^-szDecimals |
invalid_price | Price off the grid | >5 significant figures, or too many decimals |
below_min_notional (0x0C) | Notional too small | Opening leg under minNotional |
market_required | Missing ?market= | Engine ≥ c879f7f made it mandatory |
market_unsupported | Unknown market | Including all against a pre-c879f7f engine |
ApiError
Every non-2xx from the BFF throws:
class ApiError extends Error {
status: number;
path: string;
body: { error: string };
isNotFound: boolean; // 404
isUnauthorized: boolean; // 401 / 403
isServer: boolean; // >= 500
}Retry policy lives in exactly one place — TanStack Query's retry predicate. Network errors and
5xx retry; status < 500 does not, because a 4xx will answer identically three more times.
The CORS error that is not a CORS error
On staging, a duplicate party hint made the engine return 502. Cloudflare replaced that response with its own HTML error page — which carries no CORS headers. The browser therefore reported a CORS failure.
Before debugging CORS config, check the origin's actual status. A CORS error on a request that normally works is far more often an upstream 5xx wearing a disguise.
Silent read degradation
Two failure modes produce plausible-looking empty data:
No DATABASE_URL
Reads return empty lists, not errors. /readyz reports the degraded state — which is the only
way to tell "no positions" from "no database".
Old engine + live open-order read
ordersForUser calls GET /v1/admin/users/{party}/orders?market=all. Against an engine older
than c879f7f that is a 400 market_unsupported, which is caught — so the live half silently
disappears and the response falls back to non-terminal mirror rows: phantom open orders.
No 5xx is emitted. Grep the logs for ordersForUser: live open-orders fetch failed.
Missing columns
error: column "size" does not existThe api was deployed before the indexer migrations. Drizzle emits an explicit column list, so this fails on the first request rather than degrading. Fix: migrate the indexer, then redeploy. See Overview.
WebSocket
| Symptom | Cause |
|---|---|
| Connects, receives nothing | ENGINE_ADMIN_TOKEN unset — the SSE bridge never started |
| Receives every market | markets= filter did not match; an unmatched filter is ignored, not empty |
SecurityError on connect | Mixed content — an https:// page opening ws://. streamWsUrl() guards this |
| Gap after reconnect | Expected. Backfill with GET /v1/events?since=<last seq> |
Front-end error surfaces
QueryCache.onError— one throttled toast per 30s window, so a backend outage does not storm the UI.- Router
errorElement(pages/error.tsx) — mainly a chunk-load fallback. After a deploy, a client holding old HTML requests a hashed chunk that no longer exists. error-screen.tsx— crash UI showing a Reference ID and Session ID, both captured to PostHog. Ask users for the Reference ID; it maps directly to the captured exception.panel-boundary.tsx— per-panel boundary so one broken panel on the trade terminal does not take down the order form beside it.