Skip to content

ws: KalshiWebSocket._stop() never retrieves a recv-loop exception (asyncio "Task exception was never retrieved") #413

Description

@TexasCoding

Context

Surfaced by the claude[bot] review of PR #403. The perps PerpsWebSocket._stop() had this exact bug and was fixed on the perps branch (feat/perps-api); the prediction-API KalshiWebSocket._stop() has the identical structure and should be fixed in tandem. Kept out of the perps PR to preserve its scope.

Problem

kalshi/ws/client.py _stop() (lines ~209-217):

if self._recv_task and not self._recv_task.done():
    with contextlib.suppress(asyncio.CancelledError, Exception):
        await asyncio.wait_for(self._recv_task, timeout=2.0)
    if not self._recv_task.done():
        self._recv_task.cancel()
        with contextlib.suppress(asyncio.CancelledError, Exception):
            await self._recv_task

When _recv_loop exits with an unhandled exception (e.g. a permanent-close-code KalshiConnectionError, or the re-raise in the catchall handler), the task is already done by the time _stop() runs. The not self._recv_task.done() guard is False, so the whole block is skipped and the stored exception is never retrieved. asyncio then logs "Task exception was never retrieved" when the task is garbage-collected.

Proposed fix

Add an elif to retrieve the exception when the task is already finished (mirrors the perps fix on feat/perps-api):

elif self._recv_task is not None:
    # Task already finished (e.g. recv loop raised on a permanent close);
    # retrieve the stored exception so asyncio doesn't log
    # "Task exception was never retrieved" when it is GC'd.
    with contextlib.suppress(asyncio.CancelledError, Exception):
        self._recv_task.exception()

Acceptance criteria

  • KalshiWebSocket._stop() retrieves the exception of an already-done recv task.
  • Regression test: a session whose recv loop exited with an exception emits no "Task exception was never retrieved" warning on _stop() (assert via caplog/warnings or a stored-exception check).
  • mypy strict + full suite pass.

Notes

Identical one-line-class fix as the perps PerpsWebSocket._stop() change (commit on feat/perps-api). Low risk; defensive only.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingwsWebSocket-related

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions