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 card
Quick answer

How 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.

Code
Meaning
Retry?
400
Bad Request · malformed body, bad params. In Go this surfaces as ValidationError. Fix the caller.
Never
401
Unauthorized · bad or expired API key / signature. In Go: AuthenticationError.
Never
403
Forbidden · valid auth but the scoped token can’t perform this action. Also AuthenticationError in Go.
Never
404
Not Found · market slug, order id, or path doesn’t exist.
Check IDs
409
Conflict · duplicate clientOrderId on POST /orders. The original order is already accepted, do NOT resubmit.
Never
429
Too Many Requests · rate limit hit. Go: RateLimitError. Respect Retry-After if present.
With backoff
500
Internal Server Error · something broke upstream.
With backoff
502
Bad Gateway · load balancer couldn’t reach the origin.
With backoff
503
Service Unavailable · deploy, maintenance, or overload.
With backoff
504
Gateway Timeout · origin didn’t respond in time. Retry, but check if the write already happened.
Carefully

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;
  }
}

How to run this

  1. 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.
  2. Save the snippet above as idempotent-order.ts, then run npx tsx idempotent-order.ts.
  3. 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 });
  }
}

How to run this

  1. 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.
  2. Save the snippet above as with-retry.ts, then run npx tsx with-retry.ts.
  3. 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;
  }
}

How to run this

  1. 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.
  2. Save the snippet above as safe-create-order.ts, then run npx tsx safe-create-order.ts.
  3. 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.
Common questions

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.

  1. Which HTTP status codes are safe to retry on the Limitless API?
    Retry 429, 500, 502, and 503 with backoff; on 429, respect the Retry-After header when present. Never retry 400, 401, 403, or 409: those mean your request, your key, or a duplicate clientOrderId is 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.
  2. What does a 409 Conflict mean when placing a Limitless order?
    A duplicate clientOrderId on POST /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 with POST /orders/status/batch using the same clientOrderId. This server-side dedupe is what makes order retries safe.
  3. What retry helpers do the Limitless SDKs include?
    The TypeScript SDK ships withRetry(fn, opts) for a single call and a @retryOnErrors decorator for a whole method; Python has a @retry_on_errors decorator in limitless_sdk.api; Go has limitless.WithRetry and limitless.NewRetryableClient. All take a status-code allow-list (stick to 429, 500, 502, 503, 504), back off exponentially, and respect Retry-After when the server sets it, falling back to the backoff formula otherwise.
  4. How do you build a deterministic idempotency key for an order?
    Derive the clientOrderId from the trade intent, for example hash(market + side + size + target_ts) or a string like strategy-marketSlug-epoch-side. Same intent, same key on every retry; new intent, new key. Never mint a fresh uuid() 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.
  5. 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’s ApiError exposes status, message, and data (the raw body); Python’s APIError exposes status_code and message; Go’s APIError carries Status, Message, and Data (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.

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 Limitless

Tier 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:

01

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.

02

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.

03

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.

Quick recall

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.

  1. 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 the subscribe payload again or the price feed silently goes dark. The bot keeps running on stale data until you notice, which can be hours.
  2. 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 with seq > snapshot.seq against 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.
  3. 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.
  4. What does a deterministic clientOrderId save you when you retry an order placement on a 5xx?
    It de-dupes on the server. Your retry sends the same clientOrderId; 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.
  5. 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