18. Error Handling & Rate Limits

Classify SDK exceptions, read server error codes, retry safely on rate limits and never resend a duplicate order

Objective

Catch the right SDK exception and react correctly to each kind: authentication errors, input errors blocked on the client, server errors with an error code, rate limits and duplicate orders (HTTP 409). Matches Sample 18 in the tutorials repo (SDK v3.3.2).

Parts A and B run offline with fake exceptions (no network calls). Only part C calls the real API, and it only reads data, it never places orders. Part C requires OTP.

Exception hierarchy

Catch the child first, the parent after.

SSIError                      message, code, status_code, response_body, headers
 ├─ AuthenticationError
 │    ├─ SmartOTPPendingError     APPROVE not pressed on the app yet (HTTP 202 / 401114)
 │    ├─ SmartOTPRejectedError    rejected / expired / unknown transaction -> request a new OTP
 │    └─ ReauthenticationRequired session cannot be refreshed -> authenticate again with a new OTP
 ├─ APIError                     code = server error code (e.g. "400210") or the HTTP status as a string
 │    └─ DuplicateRequestError    HTTP 409, clientRequestId/batchRequestId already used today
 ├─ RateLimitError               retry_after_ms (milliseconds), limit, remaining
 ├─ ValidationError              invalid data, blocked on the client (request not sent)
 └─ WebSocketError
      └─ TradingWSError          status, error_code, server_message

Samples in the repo

LanguageFile
Pythonpython/sample_18_error_handling.py, python/sample_18_error_handling_async.py
Node.jsnode/sample_18_error_handling.js
.NETdotnet/Sample18ErrorHandling.cs
Gogo/sample_18_error_handling.go

This page uses Python for illustration. Exception names differ a little between languages (for example .NET uses SsiException and RateLimitException); see the Exceptions page of each SDK. Repo: ssi-fastconnect-v3-tutorials.

Prerequisites

  • Credentials in config.json, see Tutorial 1. The repo samples load the config through config.py; placeholders are used here to keep the code short.
  • auth_helper.py (token cache, Tutorial 1) and an OTP for the first login (only needed for part C).

Sample Code — Sync

The code below is condensed from the repo sample and keeps the same flow.

python/sample_18_error_handling.py
import dataclasses
import time

from ssi_sdk import Auth, Data, Config
from ssi_sdk.enums import ServerErrorCode
from ssi_sdk.exceptions import (
    APIError,
    AuthenticationError,
    DuplicateRequestError,
    RateLimitError,
    ReauthenticationRequired,
    SmartOTPPendingError,
    SmartOTPRejectedError,
    SSIError,
    ValidationError,
)
from auth_helper import ensure_auth

config = Config(
    client_id="<your_client_id>",
    api_key="<your_api_key>",
    api_secret="<your_api_secret>",
    private_key="<your_private_key>",
)
MAX_ATTEMPTS = 3
MAX_WAIT_MS = 10_000


def explain(e: Exception) -> str:
    """Return how to handle an exception. Except order: child first, parent after."""
    try:
        raise e
    except RateLimitError as err:
        return (f"Rate limit hit (limit={err.limit}, remaining={err.remaining}). "
                f"Wait {err.retry_after_ms} ms then call again (read requests only).")
    except DuplicateRequestError as err:  # must come BEFORE APIError
        return f"409 duplicate id ({err.message}). The original order may already be on the exchange: check the order book, do NOT resend."
    except ValidationError as err:
        return f"Invalid data, request not sent: {err.message}. Fix the input and run again."
    except APIError as err:
        known = ServerErrorCode.from_value(err.code)
        name = known.name if known else "code not in the SDK"
        return f"Server rejected: code={err.code} ({name}) status={err.status_code}: {err.message}"
    except SmartOTPPendingError:  # AuthenticationError subclasses come BEFORE the parent
        return "APPROVE not pressed on the SSI iBoard app yet: press APPROVE and keep waiting, or raise the poll count."
    except SmartOTPRejectedError as err:
        return f"Smart OTP rejected/expired ({err.message}): request a new OTP."
    except ReauthenticationRequired:
        return "Session cannot be refreshed: authenticate again with a new OTP."
    except AuthenticationError as err:
        return f"Authentication error: {err.message}"
    except SSIError as err:
        return f"Other SDK error ({type(err).__name__}): {err.message}"


def call_with_retry(fn, *args, **kwargs):
    """Call a READ request, honouring retry_after_ms on rate limit. Do NOT use for placing/cancelling orders."""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            return fn(*args, **kwargs)
        except RateLimitError as e:
            if attempt == MAX_ATTEMPTS:
                raise
            wait_ms = min(e.retry_after_ms or config.retry_delay_ms, MAX_WAIT_MS)
            print(f"  Rate limit, waiting {wait_ms:.0f} ms (attempt {attempt}/{MAX_ATTEMPTS})...")
            time.sleep(wait_ms / 1000)


# Part A (offline): settings in milliseconds
print("--- A. Settings in milliseconds ---")
print(f"timeout_ms={config.timeout_ms} retry_delay_ms={config.retry_delay_ms} "
      f"otp_poll_interval_ms={config.otp_poll_interval_ms} otp_poll_max_wait_ms={config.otp_poll_max_wait_ms}")
try:
    dataclasses.replace(config, timeout=5)  # the old name is gone
except TypeError as e:
    print(f"The old name 'timeout' is rejected as expected: {e}")

# Part B (offline): each kind of error with a fake exception
print("\n--- B. How to handle each kind of error (fake exceptions, no network) ---")
samples = [
    RateLimitError("Too many requests", retry_after_ms=1500, limit=10, remaining=0),
    ValidationError("price must be > 0"),
    DuplicateRequestError("clientRequestId already used", code="409", status_code=409),
    APIError("Timeframe not supported", code="400210", status_code=400),
    APIError("Unknown code", code="999999", status_code=400),
    SmartOTPPendingError("not approved yet"),
    SmartOTPRejectedError("transaction expired"),
    ReauthenticationRequired("refresh token revoked"),
]
for exc in samples:
    print(f"- {type(exc).__name__}: {explain(exc)}")

# Part C (real, read-only): authenticate, call the API, catch errors
print("\n--- C. Real API call (read-only) ---")
with Auth(config) as auth:
    try:
        ensure_auth(auth, otp="<your_otp>")
    except (SmartOTPPendingError, SmartOTPRejectedError, ReauthenticationRequired, AuthenticationError) as e:
        raise SystemExit(f"Authentication failed: {explain(e)}")

    with Data(auth) as data:
        try:
            rows = call_with_retry(data.market_data.get_master_data)
            print(f"get_master_data: {len(rows)} symbols")
        except SSIError as e:
            print(f"get_master_data failed: {explain(e)}")

        # Weekly/monthly candles are not supported: ValidationError on the client
        try:
            data.market_data.get_ohlc_1week_historical("SSI", "2026/01/01 00:00:00", "2026/03/27 23:59:59")
        except SSIError as e:
            print(f"get_ohlc_1week_historical: {explain(e)}")

Sample Code — Async

Async uses the same flow; the only differences are AsyncAuth / AsyncData, await, and asyncio.sleep instead of time.sleep in the retry helper.

python/sample_18_error_handling_async.py
import asyncio

from ssi_sdk import AsyncAuth, AsyncData, Config
from ssi_sdk.exceptions import RateLimitError, SSIError
from auth_helper import ensure_auth_async

config = Config(...)
MAX_ATTEMPTS = 3
MAX_WAIT_MS = 10_000

# explain(e) is the same as in the sync version


async def call_with_retry(fn, *args, **kwargs):
    """Call a READ request, honouring retry_after_ms on rate limit. Do NOT use for placing/cancelling orders."""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        try:
            return await fn(*args, **kwargs)
        except RateLimitError as e:
            if attempt == MAX_ATTEMPTS:
                raise
            wait_ms = min(e.retry_after_ms or config.retry_delay_ms, MAX_WAIT_MS)
            await asyncio.sleep(wait_ms / 1000)


async def main():
    async with AsyncAuth(config) as auth:
        await ensure_auth_async(auth, otp="<your_otp>")

        async with AsyncData(auth) as data:
            try:
                rows = await call_with_retry(data.market_data.get_master_data)
                print(f"get_master_data: {len(rows)} symbols")
            except SSIError as e:
                print(f"get_master_data failed: {explain(e)}")


asyncio.run(main())

Key rules

RuleExplanation
Catch the child first, the parent afterDuplicateRequestError must come before APIError; SmartOTPPendingError, SmartOTPRejectedError and ReauthenticationRequired must come before AuthenticationError. Reverse the order and the specific branch never runs
Durations are in millisecondsEvery time setting in SDK 3.3.x has an *_ms suffix (timeout_ms, retry_delay_ms, otp_poll_interval_ms...). The old names (timeout, retry_delay, poll_interval...) raise TypeError
Only GET requests are retried automaticallyPlace/modify/cancel requests (POST/PUT/DELETE) are never retried automatically. After a network error, check the order book first, do not blindly resend
DuplicateRequestError (409)The clientRequestId or batchRequestId was already used today. The original order may already be on the exchange: look it up by id and do not resend
Compare error codes through the enumAPIError.code can be a string or a number. Use ServerErrorCode.from_value(code); it returns None for an unknown code and never raises
Never print sensitive dataDo not print e.headers, e.response_body or config: they may contain tokens or account numbers. Print only message, code, status_code

Notes

  • Retry only transient errors: call_with_retry catches only RateLimitError, waits retry_after_ms (capped by MAX_WAIT_MS) and tries again up to MAX_ATTEMPTS times. Do not use it for placing/cancelling orders. If you build your own retry for orders, keep the same client_request_id across attempts so no second order is created, and check the order book before resending.
  • ValidationError needs no retry: bad data is blocked before the request is sent; fix the input and run again. For example get_ohlc_1week_historical always raises ValidationError because the server does not support 1w / 1M candles (error code 400210).
  • Server error codes: look up their meaning in Error Codes (ErrCode) and ServerErrorCode in Exceptions (Python). Some codes are reused for several meanings, so trust the number rather than the text.
  • Errors inside a batch are data in each order's result, not exceptions: see Batch Orders.
  • Trading WebSocket raises TradingWSError (import it from ssi_sdk, with status, error_code, server_message): see Trading WebSocket.
  • Details for each exception and more examples: Exceptions (Python).

On this page