Welcome to API Academy
Module 10 · Real-Time · ~22 min
Error handling.
By the end of this module, your bot can fall down without losing money, duplicate orders blocked, transient failures retried, weird responses logged with enough context that you’ll actually be able to debug them later.
To get there, you’ll classify every HTTP status the API returns, send orders with deterministic clientOrderId keys that make retries safe, and branch on the SDK’s typed error hierarchy to log exactly the context a support ticket needs.
Real-Time tier · Reference cardHow do you handle errors from the Limitless API?
Classify by status code: 4xx means your request is wrong and retrying won’t help, 5xx means the server is struggling and a retry might. Retry only on 429, 500, 502, 503, and 504 with jittered exponential backoff, respecting any Retry-After header the server sends. Send every order with a deterministic clientOrderId (max 128 chars) derived from the trade intent, so a retry of the same intent reuses the same key and a duplicate returns 409 Conflict instead of a second fill; a 409 on retry is a success signal, the original order is already accepted. Catch the SDK’s typed errors (ApiError in TypeScript, APIError in Python and Go) and log the status plus the raw response body. Cap total attempts and total wait so the retry loop always terminates.
Endpoints verified 2026-06-09 against the OpenAPI spec.
Section 01
HTTP error taxonomy.
Status codes are a contract. 4xx means your request is wrong and retrying won’t help; 5xx means the server is having a bad day and retrying might. Know which is which and your retry loop writes itself.
Section 02
Idempotency keys.
Networks drop responses, not just requests. If you send an order and the connection dies before the 200 gets back, you have no idea whether the order was placed. An idempotency key, a stable, client-chosen UUID per logical intent, lets you retry safely: the server deduplicates based on the key.
// Module 10, Idempotency-safe order placement.
//
// POST /orders accepts an optional `clientOrderId` field (max
// 128 chars). Resubmitting the same id returns 409 Conflict,
// the original order is already accepted, so a 409 on retry is
// a SUCCESS signal, not a failure. Build the id deterministically
// so every retry of the same intent produces the same string.
import {
HttpClient,
OrderClient,
MarketFetcher,
ApiError,
Side,
OrderType,
} from '@limitless-exchange/sdk';
const http = new HttpClient({ baseURL: 'https://api.limitless.exchange' });
const mf = new MarketFetcher(http);
const orderClient = new OrderClient({ httpClient: http, marketFetcher: mf, wallet });
interface Intent {
strategy: string; // e.g. 'mm'
marketSlug: string;
tokenId: string;
side: Side;
price: number;
size: number;
epoch: number; // your own deterministic bucket (e.g. minute)
}
// Deterministic key, same intent → same id → server dedupes.
function idFor(i: Intent): string {
return `${i.strategy}-${i.marketSlug}-${i.epoch}-${i.side}`;
}
async function placeOnce(intent: Intent) {
const clientOrderId = idFor(intent);
try {
return await orderClient.createOrder({
marketSlug: intent.marketSlug,
tokenId: intent.tokenId,
side: intent.side,
price: intent.price,
size: intent.size,
orderType: OrderType.GTC,
clientOrderId,
});
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
// Duplicate clientOrderId, the original is live. Look it up.
const status = await http.post('/orders/status/batch', {
items: [{ clientOrderId }],
});
return status.results[0];
}
throw err;
}
}
# Module 10, Idempotency-safe order placement.
#
# POST /orders accepts an optional `client_order_id` (max 128
# chars). Resubmitting the same id returns 409 Conflict, the
# original order is already accepted, so a 409 on retry is a
# SUCCESS signal, not a failure. Build the id deterministically
# so every retry of the same intent produces the same string.
import os
from eth_account import Account
from limitless_sdk.api import HttpClient, APIError
from limitless_sdk.markets import MarketFetcher
from limitless_sdk.orders import OrderClient
from limitless_sdk.types import Side, OrderType
http = HttpClient()
market_fetcher = MarketFetcher(http)
wallet = Account.from_key(os.environ["PRIVATE_KEY"])
order_client = OrderClient(http, wallet=wallet, market_fetcher=market_fetcher)
def id_for(strategy: str, market_slug: str, epoch: int, side: Side) -> str:
# Deterministic, same intent → same id → server dedupes.
return f"{strategy}-{market_slug}-{epoch}-{side.value}"
async def place_once(
market_slug: str,
token_id: str,
side: Side,
price: float,
size: float,
epoch: int,
):
client_order_id = id_for("mm", market_slug, epoch, side)
try:
return await order_client.create_order(
market_slug=market_slug,
token_id=token_id,
side=side,
price=price,
size=size,
order_type=OrderType.GTC,
client_order_id=client_order_id,
)
except APIError as e:
if e.status_code == 409:
# Duplicate clientOrderId, original is live. Look it up.
resp = await http.post(
"/orders/status/batch",
json={"items": [{"clientOrderId": client_order_id}]},
)
return resp["results"][0]
raise
// Module 10, Idempotency-safe order placement.
//
// POST /orders accepts an optional ClientOrderID (max 128 chars).
// Resubmitting the same id returns 409 Conflict, the original is
// already accepted, so a 409 on retry is a SUCCESS signal. Build
// the id deterministically so every retry of the same intent
// produces the same string.
package main
import (
"context"
"errors"
"fmt"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
type Intent struct {
Strategy string
MarketSlug string
TokenID string
Side limitless.Side
Price float64
Size float64
Epoch int64 // your own deterministic bucket (e.g. minute)
}
func idFor(i Intent) string {
return fmt.Sprintf("%s-%s-%d-%d", i.Strategy, i.MarketSlug, i.Epoch, i.Side)
}
func placeOnce(ctx context.Context, client *limitless.OrderClient, i Intent) (*limitless.OrderResponse, error) {
clientOrderID := idFor(i)
result, err := client.CreateOrder(ctx, limitless.CreateOrderParams{
OrderType: limitless.OrderTypeGTC,
MarketSlug: i.MarketSlug,
ClientOrderID: clientOrderID,
Args: limitless.GTCOrderArgs{
TokenID: i.TokenID,
Side: i.Side,
Price: i.Price,
Size: i.Size,
},
})
if err == nil {
return result, nil
}
var apiErr *limitless.APIError
if errors.As(err, &apiErr) && apiErr.Status == 409 {
// Duplicate ClientOrderID, original is live. Fine to
// treat as success; optionally look it up via
// POST /orders/status/batch with {clientOrderId}.
return nil, nil
}
return nil, err
}
How to run this
- Set LIMITLESS_API_KEY and PRIVATE_KEY (order placement needs wallet signing). Swap in a real marketSlug / tokenId, pick a resting price, and add a top-level call to placeOnce.
- Save the snippet above as idempotent-order.ts, then run npx tsx idempotent-order.ts.
- Save the snippet above as idempotent_order.py, then run python idempotent_order.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- First call prints an orderId. Run the same script a second time with the same epoch, the server returns 409 Conflict and your catch block resolves it to the original order instead of throwing. That’s idempotency working.
Generate the key once per intent, not per retry.
If you mint a fresh UUID inside the retry loop, every attempt looks like a new order and the server has no way to dedupe. The key must be stable across the whole retry sequence.
Section 03
Retry logic.
Jittered exponential backoff that also respects Retry-After headers. If the server tells you when to come back, listen. If it doesn’t, fall back to your backoff formula. Cap total attempts and total wait, a retry loop that never gives up is a memory leak with extra steps.
// Module 10, Use the SDK's built-in retry helpers.
//
// @limitless-exchange/sdk ships two retry mechanisms so you
// don't have to hand-roll your own:
// withRetry(fn, opts) , wrapper for a single call
// @retryOnErrors(opts) , decorator for a whole method
//
// Both only retry on the status-code allow-list you pass in.
// Stick to [429, 500, 502, 503, 504]. 4xx errors (except 429)
// are your bug, not a transient blip.
import {
withRetry,
retryOnErrors,
ApiError,
} from '@limitless-exchange/sdk';
// 1) Inline wrapper, one retry policy, one call.
const markets = await withRetry(
() => marketFetcher.getActiveMarkets({ limit: 100 }),
{
statusCodes: [429, 500, 502, 503, 504],
maxRetries: 3,
delays: [1000, 2000, 4000],
onRetry: (err, attempt) => {
if (err instanceof ApiError) {
console.warn(`retry ${attempt}, HTTP ${err.status}: ${err.message}`);
}
},
},
);
// 2) Decorator, every call on the method inherits the policy.
class Strategy {
@retryOnErrors({
statusCodes: [429, 500, 502, 503, 504],
maxRetries: 5,
exponentialBase: 2,
maxDelay: 60_000,
})
async refreshMarkets() {
return marketFetcher.getActiveMarkets({ limit: 100 });
}
}
# Module 10, Use the SDK's built-in retry helper.
#
# limitless_sdk.api ships a @retry_on_errors decorator that
# implements exponential backoff with a status-code allow-list.
# Stick to {429, 500, 502, 503, 504}. 4xx errors (except 429)
# are your bug, not a transient blip.
from limitless_sdk.api import APIError, retry_on_errors
from limitless_sdk.markets import MarketFetcher
market_fetcher = MarketFetcher(http_client)
@retry_on_errors(
status_codes={429, 500, 502, 503, 504},
max_retries=3,
delays=[1, 2, 4],
)
async def fetch_markets():
# On 429/5xx the decorator sleeps then re-invokes this function.
# On 400/401/403/404 the APIError propagates immediately.
return await market_fetcher.get_active_markets(limit=100)
async def main():
try:
markets = await fetch_markets()
except APIError as e:
# Exhausted the retry budget, or a non-retryable status.
print(f"status {e.status_code}: {e.message}")
raise
// Module 10, Use the SDK's built-in retry helpers.
//
// The Go SDK ships TWO options:
// limitless.WithRetry(ctx, fn, cfg) , wrap a single call
// limitless.NewRetryableClient(client, cfg), wrap the HTTP client
// so every call inherits the policy
//
// Both respect Retry-After when the server sets it, otherwise
// they fall back to ExponentialBase^attempt capped at MaxDelay.
package main
import (
"context"
"errors"
"log"
"time"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
func main() {
ctx := context.Background()
client := limitless.NewHttpClient()
retryCfg := limitless.RetryConfig{
StatusCodes: []int{429, 500, 502, 503, 504},
MaxRetries: 3,
ExponentialBase: 2.0,
MaxDelay: 60 * time.Second,
OnRetry: func(attempt int, err error, delay time.Duration) {
log.Printf("retry %d in %v: %v", attempt, delay, err)
},
}
// Option A, one-shot wrapper.
mf := limitless.NewMarketFetcher(client)
result, err := limitless.WithRetry(
ctx,
func() (*limitless.ActiveMarketsResponse, error) {
return mf.GetActiveMarkets(ctx, &limitless.ActiveMarketsParams{Limit: 100})
},
retryCfg,
)
if err != nil {
var rateLimitErr *limitless.RateLimitError
if errors.As(err, &rateLimitErr) {
log.Printf("still rate-limited after retries")
}
log.Fatal(err)
}
log.Printf("fetched %d markets", len(result.Data))
// Option B, retryable client applied to every call.
retryable := limitless.NewRetryableClient(client, retryCfg)
_ = limitless.NewMarketFetcher(retryable.Client)
}
How to run this
- Set LIMITLESS_API_KEY. This snippet only reads markets, so no PRIVATE_KEY is needed. The TypeScript example uses top-level await, run it with tsx or wrap in an async main() if your environment needs it.
- Save the snippet above as with-retry.ts, then run npx tsx with-retry.ts.
- Save the snippet above as with_retry.py, then run python -c "import asyncio, with_retry; asyncio.run(with_retry.main())" (or wrap main() in an if __name__ == "__main__" block).
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- Happy path: you see fetched 100 markets. To exercise the retry path, temporarily point the client at a bad URL or unplug your network, the onRetry hook should log three attempts with growing delays before surfacing the final error.
Section 04
Debugging with typed errors.
The SDKs wrap API errors in a typed class that carries the status, message, and, critically, the raw response body. Any correlation id or validation detail the server returns lives inside that body, so when you file a support ticket you attach error.data (TS), e.message (Python), or the full APIError (Go). Your job is to catch the typed error, log it with enough context to reproduce, and never swallow the body.
// Module 10, Catch ApiError and log the raw response body.
//
// ApiError carries { status, message, data }. `data` is the raw
// response body, grep that for any correlation id or validation
// detail the server attached. Also catch TypeError for pure
// network failures (no response at all).
import {
HttpClient,
OrderClient,
ApiError,
Side,
OrderType,
} from '@limitless-exchange/sdk';
async function safeCreateOrder(orderClient: OrderClient, params: any) {
try {
return await orderClient.createOrder(params);
} catch (error) {
if (error instanceof ApiError) {
console.error(
`[HTTP ${error.status}] ${error.message}`,
{ body: error.data, params },
);
// Decide what to do by status.
if (error.status === 429) return { retry: true };
if (error.status === 409) return { duplicate: true };
throw error;
}
if (error instanceof TypeError) {
// fetch() threw before we got a response, network failure.
console.error('network error, no response', error);
throw error;
}
throw error;
}
}
# Module 10, Catch APIError and log status + message.
#
# limitless_sdk.api.APIError exposes status_code and message.
# Log both plus your own context (order params, market slug)
# so that you can reproduce the failure later.
import logging
from limitless_sdk.api import APIError
from limitless_sdk.orders import OrderClient
from limitless_sdk.types import Side, OrderType
log = logging.getLogger("limitless")
async def safe_create_order(order_client: OrderClient, **params) -> dict | None:
try:
return await order_client.create_order(**params)
except APIError as e:
log.error(
"createOrder failed: status=%s message=%s params=%s",
e.status_code,
e.message,
params,
)
if e.status_code == 429:
return {"retry": True}
if e.status_code == 409:
return {"duplicate": True}
raise
except Exception:
log.exception("unexpected error calling createOrder")
raise
// Module 10, Unwrap the typed error hierarchy.
//
// The Go SDK ships a typed error tree:
// APIError , base (has Status, Message, Body)
// ValidationError , 400 / OrderValidationError for client-side
// AuthenticationError , 401 / 403
// RateLimitError , 429 (may carry Retry-After)
//
// Use errors.As to branch on whichever one you care about
// and log the raw body for support tickets.
package main
import (
"context"
"errors"
"log"
limitless "github.com/limitless-labs-group/limitless-exchange-go-sdk/limitless"
)
func safeCreateOrder(
ctx context.Context,
client *limitless.OrderClient,
params limitless.CreateOrderParams,
) (*limitless.OrderResponse, error) {
result, err := client.CreateOrder(ctx, params)
if err == nil {
return result, nil
}
var validErr *limitless.ValidationError
var authErr *limitless.AuthenticationError
var rateLimitErr *limitless.RateLimitError
var orderValidErr *limitless.OrderValidationError
var apiErr *limitless.APIError
switch {
case errors.As(err, &orderValidErr):
log.Printf("client-side validation failed: %v", orderValidErr)
case errors.As(err, &validErr):
log.Printf("400 bad request: %v", validErr)
case errors.As(err, &authErr):
log.Printf("auth failure (401/403): %v, check API key", authErr)
case errors.As(err, &rateLimitErr):
log.Printf("429 rate limited: %v", rateLimitErr)
case errors.As(err, &apiErr):
log.Printf("HTTP %d: %s body=%s", apiErr.Status, apiErr.Message, apiErr.Body)
default:
log.Printf("unexpected error: %v", err)
}
return nil, err
}
How to run this
- Set LIMITLESS_API_KEY and PRIVATE_KEY. To see the error branches fire, call safeCreateOrder with a deliberately bad payload, a negative size, a non-existent market slug, a stale API key, each one surfaces a different typed error.
- Save the snippet above as safe-create-order.ts, then run npx tsx safe-create-order.ts.
- Save the snippet above as safe_create_order.py, then run python safe_create_order.py.
- Save the snippet above as main.go inside a Go module, then run go run main.go.
- Your log line includes the HTTP status, the raw response body, and your own call params. Drop that block straight into a support ticket and the server team can reproduce without any guesswork.
Limitless API errors: what people ask
Each answer also ships invisibly as schema.org FAQ data for search engines and AI assistants. Tap a question to expand.
-
Which HTTP status codes are safe to retry on the Limitless API?
Retry 429, 500, 502, and 503 with backoff; on 429, respect theRetry-Afterheader when present. Never retry 400, 401, 403, or 409: those mean your request, your key, or a duplicateclientOrderIdis the problem, and resending changes nothing. 404 means a market slug, order id, or path doesn’t exist, so check your IDs. 504 is retryable, but carefully: the write may have already happened, so confirm before resubmitting. -
What does a 409 Conflict mean when placing a Limitless order?
A duplicateclientOrderIdonPOST /orders: the original order is already accepted, so do not resubmit. On a retry, a 409 is a success signal, not a failure. Resolve it by looking the order up withPOST /orders/status/batchusing the sameclientOrderId. This server-side dedupe is what makes order retries safe. -
What retry helpers do the Limitless SDKs include?
The TypeScript SDK shipswithRetry(fn, opts)for a single call and a@retryOnErrorsdecorator for a whole method; Python has a@retry_on_errorsdecorator inlimitless_sdk.api; Go haslimitless.WithRetryandlimitless.NewRetryableClient. All take a status-code allow-list (stick to 429, 500, 502, 503, 504), back off exponentially, and respectRetry-Afterwhen the server sets it, falling back to the backoff formula otherwise. -
How do you build a deterministic idempotency key for an order?
Derive theclientOrderIdfrom the trade intent, for examplehash(market + side + size + target_ts)or a string likestrategy-marketSlug-epoch-side. Same intent, same key on every retry; new intent, new key. Never mint a freshuuid()inside the retry loop: each attempt then looks like a new order, the server can’t dedupe, and your bot double-places on flaky networks. -
What should a bot log when an API call fails?
The HTTP status, the message, the raw response body, and your own call params. The typed errors carry it all: TypeScript’sApiErrorexposesstatus,message, anddata(the raw body); Python’sAPIErrorexposesstatus_codeandmessage; Go’sAPIErrorcarriesStatus,Message, andData(the raw response body). Any correlation id or validation detail lives in that body, so never swallow it: that block is exactly what a support ticket needs to reproduce the failure.
Section 05
Module checklist.
Tick each item once you’ve actually done it. The Continue button unlocks at 5/5.
I can classify any HTTP status code as “never retry” / “retry after wait” / “retry with backoff”
Every order-placement call uses an idempotency key generated once per intent, reused across retries
My retry helper respects Retry-After headers and falls back to jittered backoff
I cap total attempts and total wait so retry loops always terminate
I log the full ApiError.data / APIError.message body on every failed request so I can cross-reference with support
Real-Time tier complete
Ready to put the stream to work?
You’ve built the production plumbing, reconnecting WS, reconciled book, paced limiter, idempotent orders. The next step is real markets, real fills, real PnL. Take what you’ve learned and trade it on Limitless before moving into the historical data tier.
Start trading on LimitlessTier 2 complete · Real-Time
Real-time complete.
Your bot survives the bad days. When the exchange returns a 500, the network drops a packet, or two retries race each other, your code does the safe thing instead of the obvious thing, no double-fills, no ghost orders, no silent failures eating your P&L.
Concretely, your bot survives network blips, rate limits, and weird responses. Here’s what you walk away with at the end of the Real-Time tier:
A reconnecting WebSocketClient that restores subscribe_market_prices and subscribe_positions on every connect, plus a local CLOB book kept fresh by a REST-snapshot reconciler.
A client-side token-bucket paired with the SDK’s withRetry / retry_on_errors helpers, proactive pacing on the outbound path and jittered exponential backoff on the rare 429 or 5xx.
Deterministic clientOrderId keys on every order placement, typed ApiError / APIError branches on every failure, so retries are safe and post-mortems have the raw body the support team needs.
Without scrolling back, can you answer these?
Five questions across the Real-Time tier. Click each to reveal, the test is whether you can answer first.
-
Your websocket reconnects after a 30-second drop. What’s the one thing your client must do, and what breaks if it doesn’t?
Re-subscribe to every channel. WS subscriptions are per-connection, when the socket closes, the server forgets you. After reconnect, the client has to send thesubscribepayload again or the price feed silently goes dark. The bot keeps running on stale data until you notice, which can be hours. -
You take a REST snapshot, then deltas keep flowing. How do you reconcile the two without corrupting the in-memory book?
Buffer incoming deltas while the snapshot loads. Once it lands, replay only deltas withseq > snapshot.seqagainst the snapshot, then resume normal delta-application from the live stream. If you apply old deltas to a fresh snapshot or fresh deltas to an old snapshot, you double-count or skip updates, both silent. Sequence numbers are the gate. -
Why is a client-side token bucket better than reactive retry-after-429?
A bucket sized at ~80% of your published quota means you almost never hit 429 in steady state. Reactive retry pays the round-trip latency on every limit and lets requests pile up locally while you wait for the window to reset. The bucket is proactive flow control; reactive retry is firefighting. When you do see a 429 with the bucket on, it’s a sizing bug, not a code bug. -
What does a deterministic
clientOrderIdsave you when you retry an order placement on a 5xx?It de-dupes on the server. Your retry sends the sameclientOrderId; the exchange recognises it and returns the existing order’s state instead of placing a second one. Without it, every 5xx retry is a coin flip on whether you double-place. The key has to be deterministic from the intent (e.g.,hash(market+side+size+target_ts)), not random, otherwise retries generate new keys and bypass the dedupe. -
The websocket drops mid-trade. Walk through the four things your error path needs to do before resuming.
(1) Stop placing new orders, live data is gone. (2) Cancel resting quotes via REST so you don’t get filled on stale prices. (3) Reconcile current state via REST + chain (balanceOf) before assuming anything. (4) Reconnect with exponential backoff + jitter, re-subscribe channels, and only then resume trading. Skip any step and you’re flying blind on a partial picture.
Next up: history. Pulling trades, price series, and volume data out of the REST API so you can backtest strategies against real tape before you risk live capital.
Complete the checklist above to unlock