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