Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions docs/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
27 changes: 24 additions & 3 deletions kalshi/ws/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions tests/ws/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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()


# ---------------------------------------------------------------------------
Expand Down
Loading