diff --git a/docs/errors.md b/docs/errors.md index 0875aa26..a1a851c5 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -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 @@ -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. | @@ -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 @@ -115,8 +121,14 @@ 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 @@ -124,7 +136,12 @@ WebSocket failures are a separate sub-hierarchy under - **`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 @@ -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 diff --git a/kalshi/__init__.py b/kalshi/__init__.py index ac329d58..73df6f2e 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -8,13 +8,16 @@ AuthRequiredError, KalshiAuthError, KalshiBackpressureError, + KalshiConflictError, KalshiConnectionError, KalshiError, KalshiNotFoundError, + KalshiPoolExhaustedError, KalshiRateLimitError, KalshiSequenceGapError, KalshiServerError, KalshiSubscriptionError, + KalshiTimeoutError, KalshiValidationError, KalshiWebSocketError, ) @@ -250,13 +253,16 @@ "KalshiBackpressureError", "KalshiClient", "KalshiConfig", + "KalshiConflictError", "KalshiConnectionError", "KalshiError", "KalshiNotFoundError", + "KalshiPoolExhaustedError", "KalshiRateLimitError", "KalshiSequenceGapError", "KalshiServerError", "KalshiSubscriptionError", + "KalshiTimeoutError", "KalshiValidationError", "KalshiWebSocketError", "LiveData", diff --git a/kalshi/errors.py b/kalshi/errors.py index daa8dba9..cd4f8786 100644 --- a/kalshi/errors.py +++ b/kalshi/errors.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal + class KalshiError(Exception): """Base exception for all Kalshi SDK errors.""" @@ -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, ) @@ -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).""" @@ -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.""" @@ -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) diff --git a/tests/test_client.py b/tests/test_client.py index 5bff88ed..e36cb6e1 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -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( diff --git a/tests/test_exports.py b/tests/test_exports.py index 2fe8e38d..5192d36b 100644 --- a/tests/test_exports.py +++ b/tests/test_exports.py @@ -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__"