Market Data WebSocket Reconnects: The Asymmetry That Matters
Every real-time market feed I have used eventually disconnects. The interesting part is not that it reconnects — every tutorial has that — it is that two disconnects that look identical at the socket layer mean opposite things, and treating them the same gets you either stale data or a throttled credential.
The two failure modes
A. Connected, streamed, then dropped. The socket was healthy. Ticks were flowing. Something transient killed it — a network blip, a broker-side restart. The right response is to reconnect immediately. Every second of backoff here is a second of missing ticks, and the connection will almost certainly succeed on the first retry.
B. Accepted, then kicked with no data. The handshake succeeded and the socket closed before a single frame arrived. This is usually the broker rejecting you: an approval key that is exhausted, a session limit, a subscription the account cannot make. Retrying immediately makes it worse — you burn the credential faster and the provider starts treating you as abusive.
At the websockets layer, both surface as the same exception. You cannot distinguish them
from the close reason reliably.
The distinguishing bit
Track whether anything arrived on this connection — including control frames.
backoff = 1
while True:
up_ok = False # did this connection ever receive a frame?
try:
async with connect(url) as ws:
await self._send_subs(pairs, subscribe=True)
async for raw in ws:
up_ok = True
await self._handle(raw)
except Exception as e:
print(f"[ws] disconnected: {str(e)[:120]} — retrying in {backoff}s")
# streamed then dropped → retry fast.
# kicked with no data → back off (protects the approval key).
backoff = 1 if up_ok else min(backoff * 2, 30)
await asyncio.sleep(backoff)
One boolean, set on the first frame received. That is the whole mechanism, and it is the single highest-value line in the reconnect loop.
Note that up_ok is set by any frame, including the keepalive. A connection that
receives a PING and nothing else is still proof that the credential was accepted — the
silence is about market activity, not authorization. Counting only ticks would misclassify
a quiet session as a rejection and back off during exactly the hours you care about.
Reconnecting is not resubscribing
The mistake that costs the most and is easiest to miss: the socket comes back, the process looks healthy, logs are clean, and no data arrives, because subscriptions do not survive the connection.
Subscriptions are messages, not connection state. On this broker’s feed each one is an explicit registration:
{ "header": { "tr_type": "1" } } // 1 = subscribe, 2 = unsubscribe
So the subscription set has to be re-sent on every successful connect — which means it has
to live in the client object, not in the connect call. In the loop above, _send_subs
runs inside the async with, every time, from a set the client owns.
The tell that you got this wrong is the worst kind: a healthy-looking process producing nothing. No exception, no reconnect storm, no error log. Just a chart that stopped updating while the dashboard header still says “connected.”
Connected is not receiving
Which leads to the third rule: never let the UI derive “live” from socket state.
A status indicator wired to “is the socket open” will say connected while the feed is silent for a broker-side reason, or while subscriptions were lost, or during a session gap your session calendar got wrong. It is confidently green and useless.
Derive freshness from the data instead:
live last tick < 10s ago
delayed last tick < 5m ago
stale older, or unknown
That indicator tells you something the socket state cannot. It is also the one that catches the resubscribe bug, the timezone bug, and the market-hours bug — all of which present identically as “connected but no updates.”
What I would keep if I rewrote it
up_okasymmetric backoff — biggest single win, one line.- Cap the backoff (30s here). Unbounded exponential means a transient outage at 04:00 leaves you sleeping through the open.
- Subscriptions owned by the client, replayed on every connect.
- Freshness computed from last-tick timestamp, never from socket state.
- Echo the keepalive exactly as specified. It is boring, it is in the docs, and skipping it produces a disconnect at a fixed interval that looks mysterious for about a day.