Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 33 additions & 10 deletions docs/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ KalshiError # base, .status_code: int | None
├── KalshiNotFoundError # 404
├── KalshiValidationError # 400 (carries .details: dict[str, str])
├── KalshiRateLimitError # 429 (carries .retry_after: float | None)
├── KalshiConflictError # 409 (e.g., duplicate client_order_id)
├── KalshiTimeoutError # request timed out; commit-status unknown on POST
├── KalshiPoolExhaustedError # local pool full; request never sent
├── KalshiServerError # 5xx
└── KalshiWebSocketError # base for WS errors
├── KalshiConnectionError # handshake / reconnect failure
Expand All @@ -44,6 +47,7 @@ leave it `None`.
| `400` | `KalshiValidationError` | `.details` is populated from `body["details"]` or `body["errors"]` when present and dict-shaped. |
| `401` / `403` | `KalshiAuthError` | Bad signature, expired key, missing scope. |
| `404` | `KalshiNotFoundError` | Unknown ticker, missing order, etc. |
| `409` | `KalshiConflictError` | Duplicate `client_order_id` or other state conflict. |
| `429` | `KalshiRateLimitError` | `.retry_after` parsed from the `Retry-After` header if it's a non-negative finite numeric (HTTP-date form falls back to computed backoff). |
| `5xx` | `KalshiServerError` | All server-side failures. |
| anything else | `KalshiError` | Catch-all, with `status_code` set. |
Expand Down Expand Up @@ -80,16 +84,18 @@ passed via `request=Model(...)` fail at `Model(...)` construction.
Non-HTTP failures are wrapped to a typed exception with the original as
`__cause__`:

- **Timeouts** on retryable methods (`GET`, `HEAD`, `OPTIONS`) retry; once
retries are exhausted, the last `httpx.TimeoutException` is re-raised wrapped
in `KalshiError`. Timeouts on `POST` / `DELETE` raise `KalshiError`
immediately, no retry.
- **Timeouts** raise `KalshiTimeoutError`. On retryable verbs (`GET`, `HEAD`,
`OPTIONS`), the transport retries first and only raises once retries are
exhausted. On `POST` / `DELETE` the timeout is raised immediately — the
server may or may not have processed the request. For order create, query
with `client_order_id` to determine whether the request committed.
- **Connection pool exhaustion** raises `KalshiPoolExhaustedError`. The request
never reached the wire, so it's safe to retry regardless of HTTP method.
Persistent pool exhaustion means you should raise
`KalshiConfig.limits.max_connections`.
- **Connection failures** (DNS, TLS, RST) raise `KalshiError` immediately on
any verb, no retry.

There is no `KalshiTimeoutError` class. Inspect `e.__cause__` if you need to
distinguish.

## Catching everything from the SDK

```python
Expand All @@ -115,16 +121,27 @@ WebSocket failures are a separate sub-hierarchy under
reconnect attempt. Also surfaces from `ConnectionManager.send()` / `recv()`
if you call them without being connected.
- **`KalshiSubscriptionError`** — server rejected a `subscribe` /
`unsubscribe` / `update_subscription` command. Carries an optional
`error_code` field with the server's machine-readable code.
`unsubscribe` / `update_subscription` command. Carries:
- `error_code: int | None` — the server's machine-readable code (also
accepted positionally for back-compat).
- `channel: str | None` — the channel name involved.
- `client_id: int | None` — the durable client-side id used for the
command.
- `op: Literal["subscribe", "unsubscribe", "update_subscription"] | None`
— which operation was rejected.
- **`KalshiBackpressureError`** — raised from `MessageQueue.put()` when the
queue is full and the overflow strategy is `ERROR`. The receive loop treats
this as **fatal**: it broadcasts sentinels to every active iterator and
exits. See [WebSocket → Backpressure](websockets.md#backpressure).
- **`KalshiSequenceGapError`** — exposed for callers wiring their own resync
logic on top of the SDK's primitives. The built-in receive loop **does not
raise** this — it recovers from gaps silently (drops the message, clears
local orderbook state, waits for the next snapshot).
local orderbook state, waits for the next snapshot). Carries:
- `channel: str | None` — the channel where the gap appeared.
- `sid: int | None` — server-side subscription id.
- `client_id: int | None` — durable client-side id.
- `last_seq: int | None` — last in-order sequence successfully consumed.
- `next_seq: int | None` — sequence number observed that exposed the gap.

A subscription's iterator continues to yield across reconnects — the SDK
re-issues the subscribe and patches the new server-side `sid` into the durable
Expand All @@ -151,6 +168,12 @@ re-established at all.

::: kalshi.errors.KalshiRateLimitError

::: kalshi.errors.KalshiConflictError

::: kalshi.errors.KalshiTimeoutError

::: kalshi.errors.KalshiPoolExhaustedError

::: kalshi.errors.KalshiServerError

::: kalshi.errors.KalshiWebSocketError
Expand Down
6 changes: 6 additions & 0 deletions kalshi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
AuthRequiredError,
KalshiAuthError,
KalshiBackpressureError,
KalshiConflictError,
KalshiConnectionError,
KalshiError,
KalshiNotFoundError,
KalshiPoolExhaustedError,
KalshiRateLimitError,
KalshiSequenceGapError,
KalshiServerError,
KalshiSubscriptionError,
KalshiTimeoutError,
KalshiValidationError,
KalshiWebSocketError,
)
Expand Down Expand Up @@ -250,13 +253,16 @@
"KalshiBackpressureError",
"KalshiClient",
"KalshiConfig",
"KalshiConflictError",
"KalshiConnectionError",
"KalshiError",
"KalshiNotFoundError",
"KalshiPoolExhaustedError",
"KalshiRateLimitError",
"KalshiSequenceGapError",
"KalshiServerError",
"KalshiSubscriptionError",
"KalshiTimeoutError",
"KalshiValidationError",
"KalshiWebSocketError",
"LiveData",
Expand Down
56 changes: 54 additions & 2 deletions kalshi/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from typing import Literal


class KalshiError(Exception):
"""Base exception for all Kalshi SDK errors."""
Expand All @@ -23,7 +25,9 @@ def __init__(self, message: str | None = None) -> None:
message
or "This endpoint requires authentication. "
"Provide key_id + private_key_path when constructing the client, "
"or set KALSHI_KEY_ID + KALSHI_PRIVATE_KEY environment variables.",
"or set KALSHI_KEY_ID + (KALSHI_PRIVATE_KEY_PATH or KALSHI_PRIVATE_KEY) "
"environment variables. See "
"https://texascoding.github.io/kalshi-python-sdk/authentication/",
status_code=None,
)

Expand Down Expand Up @@ -58,6 +62,26 @@ def __init__(
super().__init__(message, status_code)


class KalshiConflictError(KalshiError):
"""409 Conflict (e.g., duplicate ``client_order_id``)."""


class KalshiTimeoutError(KalshiError):
"""Request timed out. The server may or may not have processed it.

On POST endpoints like order create, query the server using
``client_order_id`` to determine whether the request committed.
"""


class KalshiPoolExhaustedError(KalshiError):
"""Connection pool exhausted; request never reached the wire.

Safe to retry regardless of HTTP method. Raise
``KalshiConfig.limits.max_connections`` if this fires under normal load.
"""


class KalshiServerError(KalshiError):
"""Server-side error (5xx)."""

Expand All @@ -76,6 +100,23 @@ class KalshiConnectionError(KalshiWebSocketError):
class KalshiSequenceGapError(KalshiWebSocketError):
"""Sequence gap detected that could not be resolved via resync."""

def __init__(
self,
message: str,
*,
channel: str | None = None,
sid: int | None = None,
client_id: int | None = None,
last_seq: int | None = None,
next_seq: int | None = None,
) -> None:
self.channel = channel
self.sid = sid
self.client_id = client_id
self.last_seq = last_seq
self.next_seq = next_seq
super().__init__(message)


class KalshiBackpressureError(KalshiWebSocketError):
"""Message queue overflow with ERROR strategy."""
Expand All @@ -84,6 +125,17 @@ class KalshiBackpressureError(KalshiWebSocketError):
class KalshiSubscriptionError(KalshiWebSocketError):
"""Subscribe/unsubscribe request rejected by server."""

def __init__(self, message: str, error_code: int | None = None) -> None:
def __init__(
self,
message: str,
error_code: int | None = None,
*,
channel: str | None = None,
client_id: int | None = None,
op: Literal["subscribe", "unsubscribe", "update_subscription"] | None = None,
) -> None:
self.error_code = error_code
self.channel = channel
self.client_id = client_id
self.op = op
super().__init__(message)
49 changes: 49 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,11 +705,60 @@ def test_default_message(self) -> None:
err = AuthRequiredError()
assert "authentication" in str(err).lower()

def test_default_message_mentions_both_env_vars(self) -> None:
err = AuthRequiredError()
assert "KALSHI_PRIVATE_KEY_PATH" in str(err)
assert "KALSHI_PRIVATE_KEY" in str(err)

def test_custom_message(self) -> None:
err = AuthRequiredError("custom msg")
assert str(err) == "custom msg"


class TestKalshiSequenceGapError:
def test_default_kwargs_are_none(self) -> None:
from kalshi.errors import KalshiSequenceGapError

err = KalshiSequenceGapError("gap")
assert err.channel is None
assert err.sid is None
assert err.client_id is None
assert err.last_seq is None
assert err.next_seq is None

def test_carries_channel_sid_seq(self) -> None:
from kalshi.errors import KalshiSequenceGapError

err = KalshiSequenceGapError(
"gap",
channel="orderbook_delta",
sid=5,
last_seq=42,
next_seq=44,
)
assert err.channel == "orderbook_delta"
assert err.sid == 5
assert err.last_seq == 42
assert err.next_seq == 44


class TestKalshiSubscriptionError:
def test_positional_preserved(self) -> None:
from kalshi.errors import KalshiSubscriptionError

err = KalshiSubscriptionError("sub failed", 1234)
assert err.error_code == 1234

def test_carries_channel_op(self) -> None:
from kalshi.errors import KalshiSubscriptionError

err = KalshiSubscriptionError(
"sub failed", channel="ticker", op="subscribe"
)
assert err.channel == "ticker"
assert err.op == "subscribe"


class TestUnauthenticatedResourceGuards:
def test_orders_create_raises_auth_required(self) -> None:
config = KalshiConfig(
Expand Down
27 changes: 27 additions & 0 deletions tests/test_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,30 @@ def test_from_kalshi_import_star_exposes_all_models() -> None:
for name in kalshi.models.__all__:
assert name in namespace, f"`from kalshi import *` did not bind {name}"
assert namespace[name] is getattr(kalshi, name)


def test_kalshi_conflict_error_importable_from_kalshi() -> None:
from kalshi import KalshiConflictError, KalshiError

assert issubclass(KalshiConflictError, KalshiError)


def test_kalshi_timeout_error_importable_from_kalshi() -> None:
from kalshi import KalshiError, KalshiTimeoutError

assert issubclass(KalshiTimeoutError, KalshiError)


def test_kalshi_pool_exhausted_error_importable_from_kalshi() -> None:
from kalshi import KalshiError, KalshiPoolExhaustedError

assert issubclass(KalshiPoolExhaustedError, KalshiError)


def test_new_exceptions_in_kalshi_all() -> None:
for name in (
"KalshiConflictError",
"KalshiTimeoutError",
"KalshiPoolExhaustedError",
):
assert name in kalshi.__all__, f"{name} missing from kalshi.__all__"
Loading