Titandocs

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

ShapeWhereHow you notice
HTTP 4xx/5xxBFF routesNormal — ApiError carries status and body
HTTP 200 + ok: falseEngine proxyOnly if you check the body
Silent degradationReads with no DB, live orders against an old engineEmpty 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:

CodeMeaningUsual cause
invalid_sizeSize off the gridNot an exact multiple of 10^-szDecimals
invalid_pricePrice off the grid>5 significant figures, or too many decimals
below_min_notional (0x0C)Notional too smallOpening leg under minNotional
market_requiredMissing ?market=Engine ≥ c879f7f made it mandatory
market_unsupportedUnknown marketIncluding 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 exist

The 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

SymptomCause
Connects, receives nothingENGINE_ADMIN_TOKEN unset — the SSE bridge never started
Receives every marketmarkets= filter did not match; an unmatched filter is ignored, not empty
SecurityError on connectMixed content — an https:// page opening ws://. streamWsUrl() guards this
Gap after reconnectExpected. 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.

On this page