Summary
Bundle of small WebSocket polish items.
1. Use AWS Full Jitter for WS reconnect (match REST)
Current WS reconnect backoff: base_delay * 2**attempt + uniform(0, 0.5), capped at retry_max_delay. At attempt ≥ 6 the delay is at the cap (30s) and the jitter is a flat 0–0.5s — all clients that disconnect simultaneously (global Kalshi WS outage) reconnect within a 0.5s window. Thundering-herd risk.
REST already uses AWS Full Jitter (random.uniform(0, min(retry_max_delay, retry_base_delay * 2**attempt))) per CHANGELOG. WS reconnect was not migrated.
Fix: Switch WS reconnect to the same Full Jitter formula:
delay = random.uniform(0, min(retry_max_delay, retry_base_delay * (2 ** attempt)))
2. OrderbookDeltaPayload.side is bare str
apply_delta does levels = state.yes if side == 'yes' else state.no. Any non-'yes' value silently routes to the no-side book — 'no ' (trailing space), uppercase, or any future server value silently corrupts the no-side.
TradePayload/FillPayload/UserOrdersPayload already use SideLiteral/BookSideLiteral. OrderbookDeltaPayload wasn't tightened.
Fix: type as Literal["yes", "no"] (or shared BookSideLiteral). Pydantic rejects malformed sides at parse; dispatcher's existing except ValidationError drops the bad frame.
3. WS payload models lack populate_by_name=True
Every REST response/request model with validation_alias also sets model_config = {'extra': 'allow', 'populate_by_name': True} so callers can construct instances with the SDK field name. All eight WS payload modules with validation_alias forget populate_by_name=True, so a test fixture or replay tool must use the wire name.
Fix: Add 'populate_by_name': True to model_config on every WS payload class that uses validation_alias. Mechanical change; no runtime effect on parsing the wire format.
4. Datetime fields accept naive (tz-less) values silently
Models declare fields as bare datetime, which Pydantic v2 accepts both tz-aware and tz-naive values. Kalshi wire format is always RFC3339 with Z (UTC) so live parsing is fine, but users constructing models for tests can pass datetime.now() (naive) and the model silently accepts it. Trading SDKs and tz arithmetic bugs are a known landmine.
Fix: Replace datetime with pydantic.AwareDatetime on all response-model datetime fields. Pydantic rejects naive datetimes at construction. Note: depends on / pairs with H8 (WS timestamp fields). Add a "datetime semantics" note to docs/concepts.md.
5. put_sentinel docstring claim wrong at boundary
The _broadcast_sentinels docstring asserts: "put_sentinel is idempotent — calling twice puts two sentinels in the queue, the iterator reads the first and raises StopAsyncIteration without ever touching the second." With deque(maxlen=maxsize+1), when the buffer is at maxsize real items + 1 sentinel, a second put_sentinel evicts the leftmost element silently.
Fix: Make put_sentinel a no-op when _closed is already True (the contract is purely "wake the consumer"). Removes any chance of evicting an unread real item.
Severity & category
Bundle of low items, mostly ws + polish.
Summary
Bundle of small WebSocket polish items.
1. Use AWS Full Jitter for WS reconnect (match REST)
Current WS reconnect backoff:
base_delay * 2**attempt + uniform(0, 0.5), capped atretry_max_delay. At attempt ≥ 6 the delay is at the cap (30s) and the jitter is a flat 0–0.5s — all clients that disconnect simultaneously (global Kalshi WS outage) reconnect within a 0.5s window. Thundering-herd risk.REST already uses AWS Full Jitter (
random.uniform(0, min(retry_max_delay, retry_base_delay * 2**attempt))) per CHANGELOG. WS reconnect was not migrated.Fix: Switch WS reconnect to the same Full Jitter formula:
2.
OrderbookDeltaPayload.sideis barestrapply_deltadoeslevels = state.yes if side == 'yes' else state.no. Any non-'yes'value silently routes to the no-side book —'no '(trailing space), uppercase, or any future server value silently corrupts the no-side.TradePayload/FillPayload/UserOrdersPayloadalready useSideLiteral/BookSideLiteral.OrderbookDeltaPayloadwasn't tightened.Fix: type as
Literal["yes", "no"](or sharedBookSideLiteral). Pydantic rejects malformed sides at parse; dispatcher's existingexcept ValidationErrordrops the bad frame.3. WS payload models lack
populate_by_name=TrueEvery REST response/request model with
validation_aliasalso setsmodel_config = {'extra': 'allow', 'populate_by_name': True}so callers can construct instances with the SDK field name. All eight WS payload modules withvalidation_aliasforgetpopulate_by_name=True, so a test fixture or replay tool must use the wire name.Fix: Add
'populate_by_name': Truetomodel_configon every WS payload class that usesvalidation_alias. Mechanical change; no runtime effect on parsing the wire format.4. Datetime fields accept naive (tz-less) values silently
Models declare fields as bare
datetime, which Pydantic v2 accepts both tz-aware and tz-naive values. Kalshi wire format is always RFC3339 withZ(UTC) so live parsing is fine, but users constructing models for tests can passdatetime.now()(naive) and the model silently accepts it. Trading SDKs and tz arithmetic bugs are a known landmine.Fix: Replace
datetimewithpydantic.AwareDatetimeon all response-model datetime fields. Pydantic rejects naive datetimes at construction. Note: depends on / pairs with H8 (WS timestamp fields). Add a "datetime semantics" note todocs/concepts.md.5.
put_sentineldocstring claim wrong at boundaryThe
_broadcast_sentinelsdocstring asserts: "put_sentinel is idempotent — calling twice puts two sentinels in the queue, the iterator reads the first and raises StopAsyncIteration without ever touching the second." Withdeque(maxlen=maxsize+1), when the buffer is atmaxsizereal items + 1 sentinel, a secondput_sentinelevicts the leftmost element silently.Fix: Make
put_sentinela no-op when_closedis already True (the contract is purely "wake the consumer"). Removes any chance of evicting an unread real item.Severity & category
Bundle of low items, mostly ws + polish.