Skip to content

WS: reconnect should fast-fail on permanent close codes (1008, 4001…) #197

Description

@TexasCoding

Summary

The WS recv loop catches a bare ConnectionClosed and unconditionally calls _handle_reconnect, which runs the full ws_max_retries=10 backoff loop. Permanent server-side closes — auth failure (4001), policy violation (1008), invalid frame (1003), message too big (1009) — will fail every attempt, but the SDK never inspects ConnectionClosed.rcvd.code / sent.code to short-circuit.

Result: 10 retries with capped 30s delay each (~3–5 minutes) of doomed reconnect attempts, sign executor doing pointless work, the consumer iterator stalled with no signal, and only THEN raising. For a trading bot this looks like an outage that should be a clean fail-fast.

Location

  • kalshi/ws/client.py:167-180 — bare except ConnectionClosed
  • kalshi/ws/connection.py:140-200_handle_reconnect retry loop, no code inspection

Evidence

# kalshi/ws/connection.py (loop has no code inspection)
for attempt in range(self._config.ws_max_retries):
    delay = self._config.retry_base_delay * (2**attempt) + random.uniform(0, 0.5)
    delay = min(delay, self._config.retry_max_delay)
    ...
    try:
        ...
        self._ws = await connect(...)
        return
    except Exception:
        logger.debug("Reconnect attempt %d failed", attempt + 1)
        continue

# kalshi/ws/client.py — bare catch
except ConnectionClosed:
    if not self._running:
        break
    await self._handle_reconnect()  # always reconnects, regardless of close code

Recommended fix

Classify close codes and short-circuit permanent ones. Reasonable taxonomy (from RFC 6455 + Kalshi conventions):

Code Meaning Action
1000 Normal closure Don't reconnect; consumer exited
1001 Going away (server restart) Reconnect
1002 Protocol error Don't reconnect — raise
1003 Invalid frame Don't reconnect — raise
1006 Abnormal closure (no Close frame) Reconnect
1007 Invalid payload Don't reconnect — raise
1008 Policy violation Don't reconnect — raise
1009 Message too big Don't reconnect — raise
1010 Mandatory extension Don't reconnect — raise
1011 Server error Reconnect
4000–4999 App-specific Don't reconnect — raise (esp. 4001 auth)

Implementation:

PERMANENT_CLOSE_CODES = frozenset({1002, 1003, 1007, 1008, 1009, 1010}) | frozenset(range(4000, 5000))

except ConnectionClosed as e:
    code = (e.rcvd.code if e.rcvd else None) or (e.sent.code if e.sent else None)
    if code in PERMANENT_CLOSE_CODES:
        await self._broadcast_sentinels()
        raise KalshiConnectionError(
            f"WebSocket closed with permanent code {code}: {e.rcvd.reason if e.rcvd else ''}"
        ) from e
    if not self._running:
        break
    await self._handle_reconnect()

Tests:

  • test_close_4001_does_not_reconnect_and_raises_KalshiConnectionError
  • test_close_1008_does_not_reconnect
  • test_close_1006_reconnects
  • test_close_4001_broadcasts_sentinels_so_consumer_iterators_terminate

Severity & category

high / correctness, ws, performance

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingperformancePerformance / hot-path concernwsWebSocket-related

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions