Skip to content

feat(ws): reliability bundle (#254, #257, #255, #256) - #285

Merged
TexasCoding merged 4 commits into
mainfrom
feat/254-257-255-256-ws-reliability
May 21, 2026
Merged

TexasCoding merged 4 commits into
mainfrom
feat/254-257-255-256-ws-reliability

Conversation

@TexasCoding

Copy link
Copy Markdown
Owner

Closes #254
Closes #257
Closes #255
Closes #256

Part of #273 (post-v2.4 audit), Wave 2.

…tured 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
…pty 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
…rdown 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
…d/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
@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Code Review — WS Reliability Bundle (#254, #257, #255, #256)

Overview

This PR bundles four targeted WebSocket reliability fixes that address real correctness hazards in the orderbook/subscription lifecycle. Each fix is well-scoped and doesn't reach beyond the problem it's solving. The overall approach is solid.


What's Good

#255 stale-frame gate placement is correct.
The early-return guard in _process_frame fires before seq tracking, validation, apply, and dispatch — exactly where it needs to be. Placing it after any of those paths would have left the same bugs.

#256 structured error fields are well-designed.
Keyword-only constructor args with None defaults give callers a clean opt-in to structured routing without breaking existing catch blocks. The two-phase population (channel/client_id at put(), sid at broadcast_error) with explicit enrichment on the exception object is the right split given where each piece of information is available.

#254 stash drain is wired to the right path only.
_drain_resubscribe_stash() is appended to the resubscribe_one success path after the return on the failure/broadcast path. This matches the documented intent in the test comment and avoids replaying stale frames when resubscription itself failed.

Test coverage is thorough.
All four issues have targeted regression tests with realistic scenarios (unmapped-sid frame drops, zombie-sid frame isolation, empty-manager error propagation, sid enrichment round-trip). The stub take_stash() additions to test_multi_ticker_gap.py are necessary correctness fixes that prevent those tests from failing with AttributeError now that _drain_resubscribe_stash calls _sub_mgr.take_stash().


Issues

1. Issue numbers in source comments violate CLAUDE.md convention (style, multiple files)

kalshi/ws/backpressure.py:116, kalshi/ws/channels.py:163, kalshi/ws/client.py:218, kalshi/ws/client.py:284, kalshi/ws/client.py:291, kalshi/ws/client.py:305

CLAUDE.md says: "Don't reference the current task, fix, or callers … since those belong in the PR description and rot as the codebase evolves." References like # #256:, # #254:, # #255: are exactly this pattern — they name the issue rather than explaining the invariant. In six months a reader won't know whether issue #256 is still open, was reverted, or was superseded. The what and why should stand on their own without the ticket number.

Suggestion: Extract the invariant sentence and drop the #N prefix. For example:

# Stale frames whose sid has been reaped must not mutate the local book.
# Gate ALL downstream processing on the sid being currently mapped.

The PR description is the right home for the issue references.

2. Missing assertion in test_stash_drained_even_when_resubscribe_fails (weak regression)

tests/ws/test_w2c_reliability.py:519–583

The comment says "Stash remains for the next cycle to handle" but stub._stash is never asserted. This means the test would pass even if the implementation accidentally drained the stash on the failure path, silently swallowing the regression this test is meant to guard against.

Suggestion: Add the assertion the comment promises:

assert stub.broadcast_called
# Stash was NOT drained on the failure path — frames survive for the next cycle.
assert stub._stash != {}

3. KalshiOrderbookUnavailableError docstring is imprecise

kalshi/errors.py:79–98

The docstring says "Raised by :class:KalshiWebSocket.orderbook" — but KalshiWebSocket.orderbook() returns an _OrderbookIterator; the error is raised from _OrderbookIterator.__anext__. orderbook() itself never raises it. Readers who catch this error in a try/except around async for … in ws.orderbook(…) will be confused about the raise site.

Suggestion:

"""Local orderbook is empty between teardown and resync snapshot.

Raised by :class:`_OrderbookIterator` (returned by
:meth:`KalshiWebSocket.orderbook`) when the underlying delta stream
yields an item but the orderbook manager has no book for the
requested ticker …
"""

4. Exception mutation in broadcast_error is correct but warrants a note (minor)

kalshi/ws/channels.py:192–197

exc.sid = sub.server_sid

Mutating a live exception object is unusual and will surprise readers. It works here because KalshiBackpressureError.__init__ sets self.sid as a plain attribute (not a slot, not a property), but that's an implicit contract. A one-line comment explaining why mutation is intentional — and why the exception was designed to allow it — would prevent a future refactor from accidentally breaking this by adding __slots__ or a property.


Summary

Area Status
Correctness ✅ All four fixes are logically sound
Test coverage ✅ Thorough; one missing assertion (#2 above)
Style / conventions ⚠️ Issue-number comments in source (#1)
Docs ⚠️ Minor docstring imprecision (#3)
Performance ✅ No concerns
Security ✅ No concerns

The core changes are correct and safe to merge. Items #1 and #2 are the most worth addressing before merge — #1 for long-term maintainability, #2 for regression confidence.

@TexasCoding
TexasCoding merged commit 8120374 into main May 21, 2026
5 checks passed
@TexasCoding
TexasCoding deleted the feat/254-257-255-256-ws-reliability branch May 21, 2026 12:36
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