feat(errors): add Conflict/Timeout/PoolExhausted; structure WS error attrs; fix AuthRequired msg#226
Conversation
…attrs; fix AuthRequired msg - Add KalshiConflictError (409), KalshiTimeoutError, KalshiPoolExhaustedError. These are consumed by W1-A's _map_error / retry-loop wiring. - KalshiSequenceGapError gains channel/sid/client_id/last_seq/next_seq kwargs. - KalshiSubscriptionError gains channel/client_id/op kwargs; (message, error_code=None) positional signature preserved. - AuthRequiredError default message mentions both KALSHI_PRIVATE_KEY_PATH and KALSHI_PRIVATE_KEY. Closes #213, Closes #215 Refs #201, Refs #204 (W1-A finishes transport-side wiring of these new classes) Refs #224
Code Review — PR #226Wave 0-B: Errors-hierarchy foundation Overall this is a clean, well-scoped PR. The additive approach (new classes + new kwargs on existing ones) correctly avoids breaking changes, and the structured attrs on 🔴 Blocker — CI Failing (lint N802)Four test function names violate ruff's N802 rule (function names must be lowercase). This is why the test matrix is red on both 3.12 and 3.13.
# current (fails N802)
def test_default_kwargs_are_None(self) -> None:
# fix
def test_default_kwargs_are_none(self) -> None:
# current (all fail N802)
def test_KalshiConflictError_importable_from_kalshi() -> None:
def test_KalshiTimeoutError_importable_from_kalshi() -> None:
def test_KalshiPoolExhaustedError_importable_from_kalshi() -> None:
# fix (snake_case)
def test_kalshi_conflict_error_importable_from_kalshi() -> None:
def test_kalshi_timeout_error_importable_from_kalshi() -> None:
def test_kalshi_pool_exhausted_error_importable_from_kalshi() -> None:🟡 Minor — Misleading Docstring Wording
# current
"""...Raise ``KalshiConfig.limits.max_connections`` if this fires under normal load."""
# clearer
"""...Increase ``KalshiConfig.limits.max_connections`` if this fires under normal load."""Same wording appears in
🟢 What's Good
💭 Suggestions (non-blocking)Consider defaulting class KalshiConflictError(KalshiError):
"""409 Conflict (e.g., duplicate ``client_order_id``)."""
def __init__(self, message: str, status_code: int | None = 409) -> None:
super().__init__(message, status_code)This would let callers who catch Exercise the new exceptions in tests (not just hierarchy checks): The export tests confirm def test_new_http_exceptions_are_instantiable() -> None:
assert str(KalshiConflictError("dup order")) == "dup order"
assert KalshiTimeoutError("timed out").status_code is None
assert KalshiPoolExhaustedError("pool full").status_code is NoneSummary: One hard blocker (4 ruff N802 failures) + one minor wording nit. The substantive design is sound. Fix the lint violations and the wording and this is ready to merge. |
Code Review — PR #226 (Wave 0-B: Errors-hierarchy foundation)Overall verdict: looks good — a few minor items below. CI is still pending at time of review. The test names in the current diff are correctly snake_cased, so the N802 issues flagged in the earlier automated comment appear to have been resolved. 🟡 Docstring wording —
|
* fix(ws): drain resubscribe_one stash so gap-recovery doesn't leak captured frames resubscribe_one toggled `_stashing` around its unsubscribe+subscribe pair so data frames the server emitted during the sid handoff would be captured in `SubscriptionManager._stash` instead of being silently dropped. The drain path, however, was only wired into the full-reconnect path (`_handle_reconnect` -> `_drain_resubscribe_stash`). Single-sub gap recovery (`_handle_seq_gap` -> `resubscribe_one`) populated the stash but never drained it. Captured frames sat there until the next full reconnect: post-resubscribe deltas with seq>1 could hit the seq tracker before the snapshot they describe, mis-triggering another gap. Call the existing `_drain_resubscribe_stash` after a successful `resubscribe_one` so the new sid's stashed frames replay through `_process_frame` (preserving seq tracking + orderbook-apply order) and frames for other still-mapped sids that happened to land during the window also replay. Frames whose sid has no current mapping (the just- replaced old sid, or a sub that failed mid-cycle) are dropped with a debug log via the helper's existing branch. Failed resubscribe still early-returns after broadcast_error so we don't re-drain into a torn-down sub. Closes #254 * fix(ws): raise KalshiOrderbookUnavailableError instead of yielding empty Orderbook After a gap-triggered ``OrderbookManager.remove_by_sid`` (or the stale-sid race in #255), ``OrderbookManager.get(ticker)`` returns None between snapshot teardown and the resync snapshot landing. The underlying delta stream can yield a frame during that window; ``_OrderbookIterator.__anext__`` previously masked this by returning a fresh ``Orderbook(ticker=ticker)``. A consumer reading bid/ask off that empty book sees zero-depth, indistinguishable from a real market with no liquidity, and could place orders against an empty book. Replace the silent-empty return with a typed ``KalshiOrderbookUnavailableError`` carrying the affected ticker. Strategies can ``except`` it explicitly to halt or wait for the next snapshot. Choice rationale: raising is preferred over silently skipping (continue) because: - The iterator's API contract ("each next() yields a fresh Orderbook for ticker") is preserved without a retry budget heuristic. - "no book" is a genuine error condition the consumer needs to know about — silently looping until a snapshot arrives could mask a permanent teardown. - The error is recoverable: the consumer can drop into a fresh iterator after the resync snapshot lands. New ``KalshiOrderbookUnavailableError`` is declared in ``kalshi/errors.py`` (subclass of ``KalshiWebSocketError`` with a ``ticker: str | None`` field), exported via ``kalshi.__all__``, and documented in ``docs/errors.md``. Closes #257 * fix(ws): gate orderbook-apply on sid existence to drop stale post-teardown frames Orderbook snapshots/deltas arriving with a sid that no longer maps to a live subscription (server-initiated unsubscribe reaped the sid, or ``resubscribe_one`` tore down + reassigned a new sid) were still being ``model_validate``'d and applied to ``OrderbookManager`` before the dispatcher's downstream sid check rejected them. The effects: - A stale ``orderbook_snapshot`` re-seeds the manager under the old sid index, recreating a book the gap handler just cleared. - A stale ``orderbook_delta`` mutates the freshly-resynced book under the same ticker (e.g. flipping prices on the new sid's snapshot). Either produces inconsistent state that the consumer reads via ``get(ticker)`` until the next gap or reconnect. Add an early sid-existence check in ``_process_frame`` for the two orderbook message types: if the sid is present and ``_sub_mgr`` says it has no current subscription, log debug and short-circuit before seq tracking, validation, apply, or dispatch. Frames without an integer sid (control envelopes, untracked channels) fall through to the existing path. Closes #255 * feat(ws): structure KalshiBackpressureError with channel/sid/client_id/maxsize fields Consumer code iterating multiple subscribe queues sees `except KalshiBackpressureError as e` with only `str(e)` to discriminate which channel/sid/client_id overflowed. The error had to be parsed from the message string, which is brittle and breaks the structured-error contract #226 established for `KalshiSequenceGapError` and `KalshiSubscriptionError`. Add `__init__` with keyword-only `channel` / `sid` / `client_id` / `maxsize` fields on `KalshiBackpressureError`, mirroring the shape of `KalshiSequenceGapError`. Population path: - `MessageQueue` learns `channel` / `client_id` at construction (kwargs on `__init__`, defaults None for back-compat). The `put()` raise site fills `channel` / `client_id` / `maxsize` from the queue's own metadata so the error is fully structured at the point of failure. - `SubscriptionManager.subscribe()` threads the new subscription's `channel` + `client_id` into the default queue it constructs, so an overflow on a queue the SDK built has identity intact without caller intervention. - `SubscriptionManager.broadcast_error()` enriches `sid` from the subscription's current `server_sid` when the error is a `KalshiBackpressureError` with no sid set. Sid is intentionally NOT tracked by the queue (it's assigned at subscribe-ack and may change on resubscribe), so the broadcast site is the right place to attach it. Docs updated in `docs/errors.md`. Closes #256
KalshiTimeoutError (#226) exposes the 'may have committed' semantic so callers can branch on it (e.g., query by client_order_id before retrying an order create). 408 Request Timeout from an intermediary/CDN and 504 Gateway Timeout from upstream carry the same semantic, but routed to generic KalshiError and KalshiServerError respectively. Callers writing 'except KalshiTimeoutError' silently missed the HTTP-status variant. Fix: route 408 and 504 to KalshiTimeoutError before the >=500 catch-all. 500/502/503 continue to route to KalshiServerError. Closes #251
… header merge (#286) * fix(errors): preserve typed exception class when body is suppressed When a response advertises >MAX_ERROR_BODY_BYTES via Content-Length, _map_error previously returned a generic KalshiError, bypassing the status dispatch. This silently broke: - The retry loop's isinstance(err, KalshiRateLimitError) Retry-After branch (a hostile 429 with a verbose body fell back to computed backoff, ignoring the server's hint). - Caller code structured as except KalshiRateLimitError / KalshiAuthError / KalshiConflictError, which silently missed these responses. Fix: when the body is suppressed, set body={} and build a suppressed-body message, then fall through to the existing status dispatch so the typed class is preserved. The 429 Retry-After header is read from response.headers (not the body), so it still populates correctly. Closes #252 * fix(errors): map 408 and 504 to KalshiTimeoutError KalshiTimeoutError (#226) exposes the 'may have committed' semantic so callers can branch on it (e.g., query by client_order_id before retrying an order create). 408 Request Timeout from an intermediary/CDN and 504 Gateway Timeout from upstream carry the same semantic, but routed to generic KalshiError and KalshiServerError respectively. Callers writing 'except KalshiTimeoutError' silently missed the HTTP-status variant. Fix: route 408 and 504 to KalshiTimeoutError before the >=500 catch-all. 500/502/503 continue to route to KalshiServerError. Closes #251 * perf(transport): hoist invariant header merge out of retry loop The merge of config_extra / per_call_extra / body_headers is loop-invariant — only auth_headers changes per attempt (fresh timestamp). Previously a fresh 4-way dict was allocated on every attempt, including the common no-retry hot path of every resource method. Build the invariant base once before the loop; the per-attempt merge is now a 2-way dict literal over a single fresh auth_headers map. Applied to both SyncTransport.request and AsyncTransport.request. Behavior unchanged: layer order (config < per-call < body-helper < auth) and final-key wins semantics are preserved. Existing transport tests pass. Closes #262
Wave 0-B of the 2026-05-21 SDK audit umbrella. Errors-hierarchy foundation. Pure additive (new classes + new kwargs on existing classes) — no breaking changes.
Changes
KalshiConflictError(409),KalshiTimeoutError,KalshiPoolExhaustedError. Re-exported fromkalshi(added to__all__). Wave 1-A consumes these in_map_errorand the retry loop.KalshiSequenceGapErrorgains kw-only attrs:channel,sid,client_id,last_seq,next_seq.KalshiSubscriptionErrorgains kw-only attrs:channel,client_id,op. Existing(message, error_code=None)positional signature preserved.AuthRequiredErrordefault message now mentions bothKALSHI_PRIVATE_KEY_PATHandKALSHI_PRIVATE_KEY+ links to authentication docs.Tests
9 new tests; full test_exports.py + test_client.py suites (233 tests) pass; mypy --strict clean.
Closes #213, Closes #215
Refs #201, Refs #204 (W1-A finishes transport-side wiring)
Refs #224