Skip to content

Errors

Errors take two forms depending on transport — a JSON body on REST and a typed frame on WebSocket — but share the same stable error code strings. Key your retry logic off code / error; treat message as opaque since its wording may change.


REST errors

{ "error": "<code>", "message": "<human readable>", "retryAfter": 30, "detail": {} }

retryAfter (seconds) is present only on 429 rate_limited. detail is optional and varies by endpoint.

HTTP status codes

StatusUse
200Success
400Validation failed — bad input shape or out-of-range parameters
401Missing, malformed, or expired JWT
403Authenticated, but tier or role is insufficient
404Resource not found
409Conflict (username already taken on register)
429Rate limited
500Server error or upstream failure

REST error codes

HTTPerror codeEndpoint(s)Meaning
400invalid_address/api/auth/nonce, /api/auth/wallet, /v1/history/rates/:addressAddress is not 0x + 40 hex
400invalid_signature/api/auth/walletSignature length wrong or recovery doesn’t match address
400invalid_time/v1/history/*from or to couldn’t be parsed
400invalid_limit/v1/history/*limit outside [1, 10000]
400invalid_cursor/v1/history/*Cursor base64 decode failed
400validation_errorvariousGeneric validation failure — see message
401unauthorizedJWT-gated routesMissing or expired JWT
401invalid_credentials/api/auth/loginWrong password
401nonce_expired/api/auth/walletNonce TTL elapsed or already consumed
403lookback_too_far_for_tier/v1/history/*Requested window exceeds the caller’s tier limit (free = 1 day)
403insufficient_tier/api/apikeys POSTTier < api; API key issuance requires a paid subscription
403forbiddenrole-gated routesCaller role insufficient
404not_foundvariousResource doesn’t exist or isn’t yours
409username_taken/api/auth/registerUsername already exists
429rate_limitedrate-limited routesWait retryAfter seconds, then retry
500db_error/v1/history/*TimescaleDB query failed
500internal_erroranywhereCatch-all server bug; safe to retry once with backoff

WebSocket errors

{ "type": "error", "code": "<code>", "message": "<human readable>", "retryAfter": 7 }

The connection remains open after most WS errors. The exception is auth_failed / auth_error, which the server may follow with a close frame — reconnect after refreshing credentials.

WebSocket error codes

codeTriggerRecovery
rate_limitedSubscribe rate exceeded (30 actions per 10-second window)Wait retryAfter seconds, then retry the subscribe.
symbol_limit_reachedPer-session tier symbol cap hit (free = 3, paid = 100)Unsubscribe an existing symbol, or upgrade tier.
free_tier_cap_reachedFree-tier per-IP / per-userId cap (3) exceeded across all sessionsUnsubscribe in another session, or authenticate with an API key.
subscription_requiredSubscribed to a role-gated venue (e.g. arbflag without super_admin)Cannot upgrade by retrying; do not retry.
auth_error / auth_failedBad API key on {action:"auth"}, or expired JWT on subscribeRefresh credentials; reconnect.
invalid_symbol / unknown_symbolSymbol unknown to that venueFix the symbol string; consult GET /v1/symbols/{venue}/live for the canonical list.
internal_errorServer-side faultRetry with exponential backoff.

Retry decision matrix

Error categoryRetry?
429 rate_limited / WS rate_limitedYes — after retryAfter seconds
400 validation_error, invalid_*No — the request is wrong; fix before retrying
401 unauthorized / invalid_credentialsNo — refresh credentials first, then retry
403 insufficient_tier / forbidden / subscription_requiredNo — upgrade tier or role; same request will keep failing
404 not_foundNo — resource doesn’t exist
500 internal_error / db_errorYes — exponential backoff (start 1 s, double, cap at 30 s); surface after 5 failures

Python retry sketch

import time, httpx
def get_with_retry(url, headers=None, max_attempts=5):
delay = 1.0
for attempt in range(max_attempts):
r = httpx.get(url, headers=headers or {})
if r.status_code == 200:
return r.json()
body = r.json() if r.headers.get("content-type", "").startswith("application/json") else {}
code = body.get("error", "")
if r.status_code == 429:
wait = body.get("retryAfter", delay)
time.sleep(wait)
continue
if r.status_code in (400, 401, 403, 404):
raise ValueError(f"Non-retryable error {r.status_code}: {code}")
if r.status_code >= 500:
if attempt < max_attempts - 1:
time.sleep(delay)
delay = min(delay * 2, 30)
continue
raise RuntimeError(f"Request failed: {r.status_code} {code}")
raise RuntimeError("Max retry attempts exceeded")