diff --git a/CHANGELOG.md b/CHANGELOG.md index 63dc9d1..5d1a05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to kalshi-sdk will be documented in this file. ## Unreleased +### WS `run_forever()` raises on missing subscription (#175) + +`KalshiWebSocket.run_forever()` previously returned immediately when no +`subscribe_*` call had landed — `_recv_task` was `None` and the silent +no-op masked a real user mistake. Documented as a known foot-gun in +`#106` F-P-16; the callback-style example in `docs/websockets.md` +propagated the trap. + +Now raises `KalshiSubscriptionError` at the call site with an +actionable message: + +> `run_forever() requires at least one active subscription. Call +> subscribe_ticker(...) / subscribe_trade(...) / etc. (or the generic +> subscribe(channel, ...)) before run_forever() so the recv loop has +> something to drain. Registering an @ws.on(channel) callback does not +> subscribe — the server only sends frames for channels you explicitly +> subscribe to.` + +Docs updated: the callback example now shows the correct +`subscribe_ticker(...) → run_forever()` pairing with a comment +explaining that the iterator return value is unused (callbacks fan out +alongside it). + +Soft-breaking: code that relied on `run_forever()` returning silently +as a sleep-until-disconnect for a connection it never intended to use +for streaming now raises. There's no production usage of that shape; +the foot-gun was the bug. + ### Nightly integration server-omission fixes (#183) First two `server_omits_despite_required` cases caught by the post-#172 diff --git a/docs/websockets.md b/docs/websockets.md index 9674713..c3304ae 100644 --- a/docs/websockets.md +++ b/docs/websockets.md @@ -115,10 +115,19 @@ ws = KalshiWebSocket(auth=auth, config=config) async def on_ticker(msg: TickerMessage) -> None: print(msg.msg.yes_bid) -async with ws.connect(): - await ws.run_forever() +async with ws.connect() as session: + # Subscribing is what tells the server to send frames; the @ws.on + # callback above is purely the routing destination. The iterator + # returned by subscribe_ticker is unused here — callbacks fan out + # alongside iterators, so registering the callback is enough. + await session.subscribe_ticker(tickers=["EXAMPLE-25-T"]) + await session.run_forever() ``` +`run_forever()` raises ``KalshiSubscriptionError`` if no ``subscribe_*`` call +has landed in the session — a callback alone doesn't tell the server to +send frames, and the previous silent-no-op behavior was a foot-gun (#175). + `on()` works both before and after `connect()`; callbacks registered before the socket opens are buffered and applied when the session starts. diff --git a/kalshi/ws/client.py b/kalshi/ws/client.py index 083a767..f76266c 100644 --- a/kalshi/ws/client.py +++ b/kalshi/ws/client.py @@ -527,9 +527,30 @@ def decorator( return decorator async def run_forever(self) -> None: - """Block until the connection is closed. Use with callback API.""" - if self._recv_task: - await self._recv_task + """Block until the recv loop terminates. Use with the callback API. + + Requires at least one prior ``subscribe_*`` (or generic + :meth:`subscribe`) call in the same session — the recv loop is + started lazily by the subscribe machinery, and without it there + is nothing to drain. Registering an ``@ws.on(channel)`` callback + does NOT subscribe; the server only sends frames for channels you + have explicitly subscribed to, so a callback without a matching + subscribe sees nothing. + + :raises KalshiSubscriptionError: ``run_forever()`` was called + before any ``subscribe_*`` request landed (formerly a silent + no-op return — fixed in #175). + """ + if self._recv_task is None: + raise KalshiSubscriptionError( + "run_forever() requires at least one active subscription. " + "Call subscribe_ticker(...) / subscribe_trade(...) / etc. " + "(or the generic subscribe(channel, ...)) before run_forever() " + "so the recv loop has something to drain. Registering an " + "@ws.on(channel) callback does not subscribe — the server " + "only sends frames for channels you explicitly subscribe to." + ) + await self._recv_task # ------------------------------------------------------------------ # Orderbook convenience diff --git a/tests/ws/test_client.py b/tests/ws/test_client.py index 6ab435a..0c1bfa7 100644 --- a/tests/ws/test_client.py +++ b/tests/ws/test_client.py @@ -4,7 +4,10 @@ import asyncio +import pytest + from kalshi.config import KalshiConfig +from kalshi.errors import KalshiSubscriptionError from kalshi.ws.client import KalshiWebSocket, _WebSocketSession from kalshi.ws.connection import ConnectionState from tests._model_fixtures import ( @@ -361,16 +364,25 @@ async def test_run_forever_blocks_until_close(self, fake_ws, test_auth) -> None: assert not run_task.done() # Stopping the session (via context manager exit) will end run_forever - async def test_run_forever_returns_immediately_without_subscribe( + async def test_run_forever_without_subscription_raises( self, - fake_ws, + fake_ws, # type: ignore[no-untyped-def] test_auth, # type: ignore[no-untyped-def] ) -> None: + """#175: run_forever() without a prior subscribe used to silently + return because _recv_task was None. The recv loop only starts inside + the subscribe machinery; registering an @ws.on() callback alone does + NOT cause the server to send frames, so the callback would never fire + and run_forever would return immediately with no signal. + + Post-#175 the foot-gun is loud: KalshiSubscriptionError at the call + site instead of a silent no-op. + """ config = KalshiConfig(ws_base_url=fake_ws.url, timeout=5.0) ws = KalshiWebSocket(auth=test_auth, config=config) async with ws.connect() as session: - # No subscribe, so no recv_task; run_forever returns immediately - await asyncio.wait_for(session.run_forever(), timeout=1.0) + with pytest.raises(KalshiSubscriptionError, match="at least one active subscription"): + await session.run_forever() # ---------------------------------------------------------------------------