Skip to content

Commit 022e546

Browse files
authored
fix(ws): raise KalshiSubscriptionError from run_forever() with no subscribe (#185)
Closes #175. `KalshiWebSocket.run_forever()` previously returned immediately when `_recv_task` was `None` (no `subscribe_*` had landed) — a silent no-op masking the real mistake: registering an `@ws.on()` callback doesn't tell the server to send frames, only `subscribe_*` does. The callback-style example in `docs/websockets.md` propagated the foot-gun. Now raises `KalshiSubscriptionError` at the call site with an actionable message naming the missing subscribe call. Docs example updated to show the correct subscribe-then-run-forever pairing. Soft-breaking: code that relied on the silent return as a sleep-until-disconnect for a never-subscribed connection now raises. No production-shaped usage of that.
1 parent a7d93c0 commit 022e546

4 files changed

Lines changed: 79 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,34 @@ All notable changes to kalshi-sdk will be documented in this file.
44

55
## Unreleased
66

7+
### WS `run_forever()` raises on missing subscription (#175)
8+
9+
`KalshiWebSocket.run_forever()` previously returned immediately when no
10+
`subscribe_*` call had landed — `_recv_task` was `None` and the silent
11+
no-op masked a real user mistake. Documented as a known foot-gun in
12+
`#106` F-P-16; the callback-style example in `docs/websockets.md`
13+
propagated the trap.
14+
15+
Now raises `KalshiSubscriptionError` at the call site with an
16+
actionable message:
17+
18+
> `run_forever() requires at least one active subscription. Call
19+
> subscribe_ticker(...) / subscribe_trade(...) / etc. (or the generic
20+
> subscribe(channel, ...)) before run_forever() so the recv loop has
21+
> something to drain. Registering an @ws.on(channel) callback does not
22+
> subscribe — the server only sends frames for channels you explicitly
23+
> subscribe to.`
24+
25+
Docs updated: the callback example now shows the correct
26+
`subscribe_ticker(...) → run_forever()` pairing with a comment
27+
explaining that the iterator return value is unused (callbacks fan out
28+
alongside it).
29+
30+
Soft-breaking: code that relied on `run_forever()` returning silently
31+
as a sleep-until-disconnect for a connection it never intended to use
32+
for streaming now raises. There's no production usage of that shape;
33+
the foot-gun was the bug.
34+
735
### Nightly integration server-omission fixes (#183)
836

937
First two `server_omits_despite_required` cases caught by the post-#172

docs/websockets.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,19 @@ ws = KalshiWebSocket(auth=auth, config=config)
115115
async def on_ticker(msg: TickerMessage) -> None:
116116
print(msg.msg.yes_bid)
117117

118-
async with ws.connect():
119-
await ws.run_forever()
118+
async with ws.connect() as session:
119+
# Subscribing is what tells the server to send frames; the @ws.on
120+
# callback above is purely the routing destination. The iterator
121+
# returned by subscribe_ticker is unused here — callbacks fan out
122+
# alongside iterators, so registering the callback is enough.
123+
await session.subscribe_ticker(tickers=["EXAMPLE-25-T"])
124+
await session.run_forever()
120125
```
121126

127+
`run_forever()` raises ``KalshiSubscriptionError`` if no ``subscribe_*`` call
128+
has landed in the session — a callback alone doesn't tell the server to
129+
send frames, and the previous silent-no-op behavior was a foot-gun (#175).
130+
122131
`on()` works both before and after `connect()`; callbacks registered before
123132
the socket opens are buffered and applied when the session starts.
124133

kalshi/ws/client.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -527,9 +527,30 @@ def decorator(
527527
return decorator
528528

529529
async def run_forever(self) -> None:
530-
"""Block until the connection is closed. Use with callback API."""
531-
if self._recv_task:
532-
await self._recv_task
530+
"""Block until the recv loop terminates. Use with the callback API.
531+
532+
Requires at least one prior ``subscribe_*`` (or generic
533+
:meth:`subscribe`) call in the same session — the recv loop is
534+
started lazily by the subscribe machinery, and without it there
535+
is nothing to drain. Registering an ``@ws.on(channel)`` callback
536+
does NOT subscribe; the server only sends frames for channels you
537+
have explicitly subscribed to, so a callback without a matching
538+
subscribe sees nothing.
539+
540+
:raises KalshiSubscriptionError: ``run_forever()`` was called
541+
before any ``subscribe_*`` request landed (formerly a silent
542+
no-op return — fixed in #175).
543+
"""
544+
if self._recv_task is None:
545+
raise KalshiSubscriptionError(
546+
"run_forever() requires at least one active subscription. "
547+
"Call subscribe_ticker(...) / subscribe_trade(...) / etc. "
548+
"(or the generic subscribe(channel, ...)) before run_forever() "
549+
"so the recv loop has something to drain. Registering an "
550+
"@ws.on(channel) callback does not subscribe — the server "
551+
"only sends frames for channels you explicitly subscribe to."
552+
)
553+
await self._recv_task
533554

534555
# ------------------------------------------------------------------
535556
# Orderbook convenience

tests/ws/test_client.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
import asyncio
66

7+
import pytest
8+
79
from kalshi.config import KalshiConfig
10+
from kalshi.errors import KalshiSubscriptionError
811
from kalshi.ws.client import KalshiWebSocket, _WebSocketSession
912
from kalshi.ws.connection import ConnectionState
1013
from tests._model_fixtures import (
@@ -361,16 +364,25 @@ async def test_run_forever_blocks_until_close(self, fake_ws, test_auth) -> None:
361364
assert not run_task.done()
362365
# Stopping the session (via context manager exit) will end run_forever
363366

364-
async def test_run_forever_returns_immediately_without_subscribe(
367+
async def test_run_forever_without_subscription_raises(
365368
self,
366-
fake_ws,
369+
fake_ws, # type: ignore[no-untyped-def]
367370
test_auth, # type: ignore[no-untyped-def]
368371
) -> None:
372+
"""#175: run_forever() without a prior subscribe used to silently
373+
return because _recv_task was None. The recv loop only starts inside
374+
the subscribe machinery; registering an @ws.on() callback alone does
375+
NOT cause the server to send frames, so the callback would never fire
376+
and run_forever would return immediately with no signal.
377+
378+
Post-#175 the foot-gun is loud: KalshiSubscriptionError at the call
379+
site instead of a silent no-op.
380+
"""
369381
config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0)
370382
ws = KalshiWebSocket(auth=test_auth, config=config)
371383
async with ws.connect() as session:
372-
# No subscribe, so no recv_task; run_forever returns immediately
373-
await asyncio.wait_for(session.run_forever(), timeout=1.0)
384+
with pytest.raises(KalshiSubscriptionError, match="at least one active subscription"):
385+
await session.run_forever()
374386

375387

376388
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)