fix(ws): dispatcher correctness — fan-out, server-unsub cleanup, error envelope surface#137
Conversation
When a user registered an @on() callback AND an iterator subscription for the same channel, the dispatcher routed exclusively to the callback and the iterator's queue silently stayed empty (async for ... would hang on queue.get() forever). Change MessageDispatcher.dispatch() to fan out: deliver to the callback (if any) AND the subscription queue. Add a WARNING at register_callback() when an active subscription already exists for the channel so the fan-out behavior is visible. Closes #80 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the server unilaterally dropped a subscription (admin action, session expiry, channel close), the dispatcher logged the "unsubscribed" envelope and dropped it on the floor. SubscriptionManager._sid_to_client and _subscriptions retained stale entries; iterators blocked on the queue forever; long-running sessions slowly leaked. Add MessageDispatcher._handle_server_unsubscribe(): pop the sid mapping and the subscription, reset SequenceTracker state for the sid (when injected), and push a sentinel so any held async-for iterator exits cleanly. Accept the envelope in either shape: sid at the top level or nested under msg.sid. The SequenceTracker reset path is gated on an optional constructor injection (seq_tracker=None by default). The client.py wiring to pass the live tracker in is owned by the sibling recv-loop branch (#77/#83/#84/#86/#88) per Wave 3 boundaries; this commit makes the dispatcher ready to receive it. Closes #81 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AsyncAPI permits server errors to ride on a typed message envelope (an `error` field on an otherwise-typed payload) rather than the top-level type="error" form. The dispatcher previously dropped these on the floor through the unknown-type log path, leaving the user no signal. Add MessageDispatcher._surface_channel_error(): when a non-control envelope carries an `error` field, coerce it to ErrorMessage and route to the registered on_error handler. If no handler is registered, log the full envelope at WARNING (was: silently dropped). Errors with a sid the dispatcher no longer recognizes are surfaced the same way. Closes #82 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — PR #137: Dispatcher correctness (fan-out, server-unsub, error surface)Overall this is solid, targeted work. The three bugs are real, the fixes are minimal, and the test coverage is thorough. A few things worth addressing before merge. #80 — Fan-out (remove
|
| Severity | Item |
|---|---|
| Medium | if "error" in data: → should be if data.get("error") is not None: |
| Medium | Direct access to _sid_to_client / _subscriptions — track as tech debt for integration cleanup |
| Low | _surface_channel_error synthesis failure swallows error silently |
| Low | FakeSubManager doesn't test client_id != server_sid |
| Nit | import inside test methods → move to module level |
The fan-out and server-unsub fixes are ready as-is. The "error" in data issue is the one I'd want addressed before merge since it introduces a potential regression under unusual but valid server payloads.
Per bot review on PR #137: - dispatch.py: `if "error" in data:` -> `if data.get("error") is not None:` so a legitimate `"error": null` field on a typed envelope doesn't trip the channel-level error path. - dispatch.py _surface_channel_error: when ErrorMessage.model_validate fails, fall back to ErrorMessage.model_construct(...) so a registered on_error handler still fires with the raw payload. Log level escalates WARNING -> ERROR (we almost missed an alert). - tests/ws/test_dispatch.py: * Module-level imports for `logging` and `SequenceTracker` (was inline inside test methods). * FakeSubManager.add() now accepts an optional `client_id` so tests can exercise the production case where server-assigned `sid !=` client-side id. * Added test_server_unsubscribe_with_distinct_client_id covering the two-step lookup that was previously hidden by sid == client_id. * Added test_null_error_field_not_misrouted for the get/!=None guard. * Added test_channel_level_error_validation_failure_still_fires_handler for the model_construct fallback. - CHANGELOG.md: [Unreleased] -> Changed entry calling out the #80 fan-out as a behavioral change so users upgrading see the signal. Deferred (tracked elsewhere): - _sid_to_client / _subscriptions private-access layer violation: bot rightly suggests SubscriptionManager.remove_by_sid(); channels.py is the recv-loop branch's territory (#138). Track in the post-Wave-3 integration cleanup. Verify: tests/ws/test_dispatch.py 30 passed. ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Bot feedback addressed: Fixed
Deferred (tracked)
Verify: |
Per bot review on PR #137: - dispatch.py: `if "error" in data:` -> `if data.get("error") is not None:` so a legitimate `"error": null` field on a typed envelope doesn't trip the channel-level error path. - dispatch.py _surface_channel_error: when ErrorMessage.model_validate fails, fall back to ErrorMessage.model_construct(...) so a registered on_error handler still fires with the raw payload. Log level escalates WARNING -> ERROR (we almost missed an alert). - tests/ws/test_dispatch.py: * Module-level imports for `logging` and `SequenceTracker` (was inline inside test methods). * FakeSubManager.add() now accepts an optional `client_id` so tests can exercise the production case where server-assigned `sid !=` client-side id. * Added test_server_unsubscribe_with_distinct_client_id covering the two-step lookup that was previously hidden by sid == client_id. * Added test_null_error_field_not_misrouted for the get/!=None guard. * Added test_channel_level_error_validation_failure_still_fires_handler for the model_construct fallback. - CHANGELOG.md: [Unreleased] -> Changed entry calling out the #80 fan-out as a behavioral change so users upgrading see the signal. Deferred (tracked elsewhere): - _sid_to_client / _subscriptions private-access layer violation: bot rightly suggests SubscriptionManager.remove_by_sid(); channels.py is the recv-loop branch's territory (#138). Track in the post-Wave-3 integration cleanup. Verify: tests/ws/test_dispatch.py 30 passed. ruff + mypy strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
f911027 to
922234b
Compare
Code Review — Wave 3 Dispatcher Correctness (#80, #81, #82)Overall this is a solid, well-structured PR. The three bugs are real, the fixes are targeted, and the test coverage is thorough (7 new tests). A few items worth addressing before merge. Issues1. Encapsulation breach in
|
Code Review — PR #137: Dispatcher correctness (fan-out, server-unsub, error surface)Solid, targeted work. All three bugs are real, the fixes are minimal, the test suite is thorough (especially the distinct- Correction to previous review commentThe earlier comment stated that #80 — Fan-outThe fix is correct and minimal. One follow-up: the WARNING fires at #81 — Server-initiated unsubscribe cleanupThe private-attribute access is the main structural concern: # dispatch.py
client_id = self._sub_mgr._sid_to_client.pop(sid, None)
sub = self._sub_mgr._subscriptions.pop(client_id, None)
client_id = self._sub_mgr._sid_to_client.get(sid)
if client_id is not None:
self._sub_mgr.unsubscribe(client_id) # handles both dicts + sentinelThis removes the structural coupling and means a rename of #82 — Channel-level error surfaceAsymmetry with top-level if msg_type == "error":
if self._on_error is not None:
error = ErrorMessage.model_validate(data)
await self._on_error(error)
else:
logger.warning("Received error frame with no on_error handler: %s", data)
returnAlso minor: "msg": data.get("error")
if isinstance(data.get("error"), dict) # second call
else data.get("msg"),Cache it: CHANGELOGThe TestsWell-designed. Highlights:
One gap: no test for registering a callback before subscribing, then subscribing — the subscribe-after-callback fan-out path. Given #80's warning only fires for the opposite ordering, this path gets no coverage and no warning. Summary
None of these are blockers — the bugs are fixed and the behavior is correct. The layer violation is the one worth resolving before Wave 4 integrates |
…ath (#28) (#142) * fix(ws): wire SequenceTracker into MessageDispatcher PR #137 added an optional seq_tracker kwarg on MessageDispatcher.__init__ so server-initiated unsubscribe (#81) can clear stale seq state alongside the sid mapping. The wiring at the construction site in KalshiWebSocket was never landed — the dispatcher always saw seq_tracker=None and could only clear half the state. Closes #27 (post-Wave-3 integration tracker). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(ws): close #84 F-O-05 + #86 — drop exc_info leak, single parse path F-O-05 / Closes #84 (final half): The validation-failure log in MessageDispatcher.dispatch used `logger.warning("Failed to parse %s message", msg_type, exc_info=True)`. Pydantic's ValidationError stringification echoes the full input — for trade/fill/order_group channels that includes price, count, and sometimes user identifiers, dumped straight into stdout/Sentry/log infrastructure. Dropped exc_info; surface type + exception class name only. The underlying exception is still available via __cause__ for local debug. #86 dispatcher side: - Signature change: dispatch(raw: str) -> dispatch(data: dict, *, pre_validated: BaseModel | None = None). The recv loop now owns the json.loads (it already had one); the dispatcher's redundant second parse is gone. - pre_validated lets the recv loop hand off an orderbook message it already validated for the local OrderbookManager, so the dispatcher routes the same instance to the subscription queue without re-running Pydantic. Eliminates the double-validate the issue called out (~3 µs/msg). - json import dropped from dispatch.py (now unused). Recv loop client.py side (in the previous commit alongside the seq_tracker wiring): _process_frame captures the typed orderbook message and passes pre_validated to dispatch. Test fallout: - 21 tests in test_dispatch.py updated from `dispatcher.dispatch(raw)` to `dispatcher.dispatch(json.loads(raw))` via sed. - Dropped test_dispatch_invalid_json — that path now lives in the recv loop's exception ladder (covered by test_malformed_frame_logged_with_traceback_and_continues). - 2 tests in test_recv_loop_hardening.py monkey-patched dispatch with the old (raw: str) signature; updated to (data: dict[str, Any], **kw). - New regression: test_dispatch_pre_validated_skips_revalidation — asserts dispatch routes the same instance when pre_validated matches the expected model_cls. Closes #84 Closes #86 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review(#142): drop false __cause__ claim in dispatch parse-fail log Per bot review on PR #142: the comment said "the underlying exception is preserved on __cause__ for debug" — but the except block catches, logs the type name, and returns. Nothing is re-raised, so no __cause__ chain is established. Dropped the misleading sentence; kept the security rationale (Pydantic input echo). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Wave 3 — three dispatcher correctness fixes in
kalshi/ws/dispatch.py. Three commits, single PR.#80 — callback + iterator on same channel⚠️ semantic change
Before: registering a callback for a channel silently suppressed queue delivery; the iterator stopped receiving.
After: messages fan out to both. A WARNING fires at
register_callbacktime if an active subscription already exists, since some users may have relied on the suppress-side-effect.Test impact: the existing
test_callback_modeassertedqsize == 0; updated toqsize == 1to match fan-out. Callback-only users now retain queued messages until queue overflow (DROP_OLDEST safely drops; ERROR-strategy channels would raise aftermaxsize=1000).#81 — server-initiated unsubscribe reaps sid mappings
_sid_to_clientpreviously leaked entries on server-initiated unsubscribe (only client-initiated unsubscribe ran the full cleanup). Subsequent re-subscriptions could collide. Server-side unsubscribe now runs the same teardown path.#82 — channel-level error envelopes surface via
on_errorError envelopes carrying a sid the dispatcher no longer recognized (post-unsubscribe race) were swallowed. Now routed through the dispatcher's
on_errorhook, with a fallback WARNING log if no hook is registered.The server-unsubscribe handler should also reset
SequenceTracker._last_seq[sid]to fully close the leak. The dispatcher now accepts an optionalseq_tracker=Noneconstructor kwarg, butkalshi/ws/client.py(owned by the recv-loop-overhaul branch #77/#83/#84/#86/#88) wires the tracker and still doesn't pass it through. The sid-mapping leak — the primary bug — IS fully fixed here; the seq-tracker reset is a one-line wiring change to apply when integrating with the recv-loop branch (client.py:97-100area).I'll handle this wiring during the post-Wave-3 integration when both branches merge.
Wave 3 boundaries
No touches to
kalshi/ws/client.py,channels.py, ororderbook.py.Closes #80
Closes #81
Closes #82
Test plan
uv run ruff check .cleanuv run mypy kalshi/clean (strict)