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_messageSamples in the repo
| Language | File |
|---|---|
| Python | python/sample_18_error_handling.py, python/sample_18_error_handling_async.py |
| Node.js | node/sample_18_error_handling.js |
| .NET | dotnet/Sample18ErrorHandling.cs |
| Go | go/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 throughconfig.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.
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.
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
| Rule | Explanation |
|---|---|
| Catch the child first, the parent after | DuplicateRequestError must come before APIError; SmartOTPPendingError, SmartOTPRejectedError and ReauthenticationRequired must come before AuthenticationError. Reverse the order and the specific branch never runs |
| Durations are in milliseconds | Every 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 automatically | Place/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 enum | APIError.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 data | Do 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_retrycatches onlyRateLimitError, waitsretry_after_ms(capped byMAX_WAIT_MS) and tries again up toMAX_ATTEMPTStimes. Do not use it for placing/cancelling orders. If you build your own retry for orders, keep the sameclient_request_idacross attempts so no second order is created, and check the order book before resending. ValidationErrorneeds no retry: bad data is blocked before the request is sent; fix the input and run again. For exampleget_ohlc_1week_historicalalways raisesValidationErrorbecause the server does not support1w/1Mcandles (error code400210).- Server error codes: look up their meaning in Error Codes (ErrCode) and
ServerErrorCodein 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 fromssi_sdk, withstatus,error_code,server_message): see Trading WebSocket. - Details for each exception and more examples: Exceptions (Python).