fix(ws): raise KalshiSubscriptionError from run_forever() with no subscribe (#175)#185
Conversation
…scribe (#175) Previously, `KalshiWebSocket.run_forever()` returned immediately when `_recv_task` was None (no `subscribe_*` call had ever landed). Silent no-op masked a real user mistake: registering an `@ws.on(channel)` callback doesn't tell the server to send frames — only `subscribe_*` does that. Without an explicit subscribe, callbacks never fire and `run_forever()` returned with no signal. Now raises `KalshiSubscriptionError` at the call site with an actionable message pointing to the missing subscribe call. Docs updated: the callback-style example in `docs/websockets.md` showed the exact foot-gun pattern (`@ws.on("ticker")` + `run_forever()` with no subscribe). Now shows the correct `session.subscribe_ticker(...)` → `run_forever()` pairing with a comment explaining the iterator return value is unused when callbacks are the routing destination. Soft-breaking: code that relied on `run_forever()` returning silently as a sleep-until-disconnect for a connection never intended for streaming now raises. There's no production usage of that shape — the foot-gun was the bug.
Code Review — PR #185:
|
| Area | Status |
|---|---|
| Correctness | ✅ Clean fix, right layer |
| Tests | ✅ Inversion is correct; edge case (completed task) optional |
| Docs | ✅ Callback example now shows correct pairing |
| CHANGELOG | ✅ Accurate and complete |
| Style |
The style nits are minor and don't affect correctness. LGTM otherwise.
Bot round-2 found one item to action and one based on a misreading of
convention.
Actioned:
* ROADMAP.md "Open trackers" trimmed from four lines of tombstone prose
("None. The previous entries here (#45, #53, #106) are all closed:
... ") down to "None." The why-they-closed history will rot by the
next release; anyone curious can run `gh issue view 45` or check git
history.
Not actioned:
* Bot suggested changing CHANGELOG.md "Pre-release docs audit (#179)"
to "(#188)" on the claim that "every other CHANGELOG entry uses the
PR number." This is backwards. Existing CHANGELOG entries
(#175, #176, #177, #178, #183) are all *issue* numbers; the PRs that
closed them were #185, #187, #186, #182, #184 respectively. Verified
via `gh issue view <N> --json closedByPullRequestsReferences`. The
convention is "(#<issue-number>)" — using #179 is correct.
mkdocs --strict still clean.
Closes #175.
The foot-gun
KalshiWebSocket.run_forever()previously returned immediately when_recv_taskwasNone:_recv_taskis only set inside_do_subscribe()(via_ensure_recv_loop()), so any session that registered an@ws.on(channel)callback but never called asubscribe_*would have_recv_task is None→ silent return. No messages, no error, no signal.The docs themselves propagated the trap.
docs/websockets.mdshowed exactly this pattern:Registering an
@ws.on()callback doesn't tell the server to send frames — onlysubscribe_*does that. So the callback never fires, andrun_forever()returns immediately with no signal.Fix (option B per the issue)
Raise
KalshiSubscriptionErrorat the call site with an actionable message:Errors at the obvious failure point beat silent no-ops.
Docs
docs/websockets.mdcallback example now shows the correct pairing:Plus a follow-on note pointing at the new error behavior.
README WS section was sanity-checked — only shows the iterator-style pattern, no foot-gun to fix there.
Test
tests/ws/test_client.py::TestRunForever::test_run_forever_without_subscription_raisesreplaces the priortest_run_forever_returns_immediately_without_subscribe(which was asserting the buggy behavior). The companiontest_run_forever_blocks_until_closestill exercises the correct subscribe-then-run-forever path.Soft-breaking footprint
Code that relied on
run_forever()returning silently as a sleep-until-disconnect for a connection it never intended to use for streaming will now raise. There's no production-shaped usage of that — the foot-gun was the bug.Verification
uv run pytest tests/ --ignore=tests/integration→ 2114 passed, 0 warnings.uv run ruff check .→ clean.uv run mypy kalshi/→Success: no issues found in 76 source files.Out of scope
The sibling foot-gun #177 (
run_forever()lacks graceful shutdown / signal-driven stop path) stays in its own issue. This PR is purely the silent-no-op fix.