Skip to content

feat(errors): add Conflict/Timeout/PoolExhausted; structure WS error attrs; fix AuthRequired msg#226

Merged
TexasCoding merged 2 commits into
mainfrom
audit/w0b-errors-hierarchy
May 21, 2026
Merged

feat(errors): add Conflict/Timeout/PoolExhausted; structure WS error attrs; fix AuthRequired msg#226
TexasCoding merged 2 commits into
mainfrom
audit/w0b-errors-hierarchy

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

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

  • New exceptions: KalshiConflictError (409), KalshiTimeoutError, KalshiPoolExhaustedError. Re-exported from kalshi (added to __all__). Wave 1-A consumes these in _map_error and the retry loop.
  • KalshiSequenceGapError gains kw-only attrs: channel, sid, client_id, last_seq, next_seq.
  • KalshiSubscriptionError gains kw-only attrs: channel, client_id, op. Existing (message, error_code=None) positional signature preserved.
  • AuthRequiredError default message now mentions both KALSHI_PRIVATE_KEY_PATH and KALSHI_PRIVATE_KEY + links to authentication docs.
  • docs/errors.md updated with new classes and structured attrs.

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

…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
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — PR #226

Wave 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 KalshiSequenceGapError / KalshiSubscriptionError fill a genuine gap for callers who need context on failures.


🔴 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.

tests/test_client.py:719

# current (fails N802)
def test_default_kwargs_are_None(self) -> None:
# fix
def test_default_kwargs_are_none(self) -> None:

tests/test_exports.py:42, 48, 54

# 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

KalshiPoolExhaustedError says "Raise KalshiConfig.limits.max_connections" — but "raise" here means the Python keyword, not "increase a config value". This is confusing.

kalshi/errors.py

# 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 docs/errors.md:

"you should raise KalshiConfig.limits.max_connections"


🟢 What's Good

  • Back-compat preserved on KalshiSubscriptionErrorerror_code stays positional before *; existing callers KalshiSubscriptionError("msg", 1234) still work without changes.
  • KalshiSequenceGapError.__init__ — keyword-only attrs with None defaults are the right pattern; no risk of positional arg order bugs.
  • Inheritance is correctKalshiConflictError, KalshiTimeoutError, KalshiPoolExhaustedError all extend KalshiError directly, inheriting the status_code parameter for when Wave 1-A wires _map_error.
  • AuthRequiredError message improvement — adding both KALSHI_PRIVATE_KEY_PATH and KALSHI_PRIVATE_KEY and linking to the auth docs is a meaningful UX improvement.
  • test_new_exceptions_in_kalshi_all — good catch-all to guard against future __all__ drift.
  • test_default_message_mentions_both_env_vars — pins the exact env var names in a test so the message can't silently regress.

💭 Suggestions (non-blocking)

Consider defaulting status_code=409 in KalshiConflictError:

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 KalshiConflictError inspect .status_code without requiring Wave 1-A to pass it explicitly. Minor, and easily deferred to Wave 1-A if you prefer consistency with how _map_error handles all status codes.

Exercise the new exceptions in tests (not just hierarchy checks):

The export tests confirm issubclass relationships but don't instantiate the new classes. A one-liner per class confirms the constructor doesn't blow up unexpectedly:

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 None

Summary: 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.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

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 — KalshiPoolExhaustedError

errors.py:74

# current
"""...Raise ``KalshiConfig.limits.max_connections`` if this fires under normal load."""
# ↑ "Raise" reads as the Python keyword

# suggested
"""...Increase ``KalshiConfig.limits.max_connections`` if this fires under normal load."""

🟡 ASCII tree alignment — docs/errors.md

docs/errors.md:28KalshiTimeoutError has an extra leading space before the # comment, breaking the visual column alignment with the other entries:

├── KalshiConflictError              # 409 (e.g., duplicate client_order_id)
├── KalshiTimeoutError                # request timed out ...   ← extra space
├── KalshiPoolExhaustedError         # local pool full ...

🟡 client_id attr is untested on both new structured errors

KalshiSequenceGapError.client_id and KalshiSubscriptionError.client_id are never set in tests — only channel, sid, op, last_seq, next_seq are exercised. Happy to accept as-is since the omission is symmetric and the attr is straightforward, but a one-liner assertion would close the gap:

# test_client.py — TestKalshiSequenceGapError.test_carries_channel_sid_seq
assert err.client_id is None  # not set above, verify it defaults

# test_client.py — TestKalshiSubscriptionError.test_carries_channel_op
err2 = KalshiSubscriptionError("sub failed", channel="ticker", op="subscribe", client_id=7)
assert err2.client_id == 7

🔵 Hint for Wave 1-A — KalshiConflictError and status_code

KalshiConflictError inherits KalshiError.__init__(message, status_code=None) unchanged. When Wave 1-A wires _map_error, it should pass status_code=409 explicitly — the base class supports it but nothing in this PR enforces it:

# _map_error (Wave 1-A)
if status == 409:
    return KalshiConflictError(message=str(message), status_code=status)

Same pattern used by KalshiAuthError, KalshiNotFoundError, etc. Not a blocker — just flagging so it doesn't get dropped.


🔵 URL in AuthRequiredError default message

The hardcoded docs URL (https://texascoding.github.io/kalshi-python-sdk/authentication/) is a nice touch for discoverability, but it will silently rot if the docs ever move. Not worth blocking on, but a comment in the code noting the URL should stay in sync with mkdocs.yml nav could help future maintainers.


What's well done

  • Purely additive — no breaking changes; error_code positional compat on KalshiSubscriptionError preserved ✓
  • Literal type for op is the right call — precise without overhead ✓
  • Docstrings on KalshiTimeoutError and KalshiPoolExhaustedError explain the POST idempotency hazard clearly ✓
  • Export + __all__ wiring is consistent with the rest of the module ✓
  • Test coverage is solid for the main happy/default paths ✓
  • Wave scoping is correct — transport wiring deferred to Wave 1-A ✓

@TexasCoding
TexasCoding merged commit f64c07f into main May 21, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the audit/w0b-errors-hierarchy branch May 21, 2026 10:19
TexasCoding added a commit that referenced this pull request May 21, 2026
* 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
TexasCoding added a commit that referenced this pull request May 21, 2026
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
TexasCoding added a commit that referenced this pull request May 21, 2026
… 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant