Error Handling & Reconnection
Handshake errors
| HTTP code | Cause | Action |
|---|---|---|
426 | Malformed upgrade (missing/invalid Sec-WebSocket-Key) | Fix the WebSocket client |
401 | Missing X-API-Key header | Add the header |
403 | Invalid, revoked, or expired key | Request a new key |
429 | Rate or connection limit hit | Back off, see Rate Limits |
Close codes
| Code | Reason | Meaning | Reconnect? |
|---|---|---|---|
1000 | key_expired | Key expiration date passed | No — get a new key |
1000 | key_invalidated | Administrator revoked the key | No — get a new key |
1008 | too_slow | Client lagged > 10 messages behind | Yes — process faster |
1008 | rate_limit_exceeded | Sent > 3 messages / minute to the server | Yes — stop sending |
1009 | frame_too_large | Sent a frame larger than 1 KB | Yes — fix the client |
Reconnection strategy
Exponential backoff, capped at 5 minutes:
attempt 1 → 1 s
attempt 2 → 2 s
attempt 3 → 4 s
attempt 4 → 8 s
… cap at 300 s
Reset the counter after a successful connection.
Decision logic:
| Trigger | Action |
|---|---|
Close key_expired / key_invalidated | Stop |
Close rate_limit_exceeded | Wait 60 s, stop sending client messages, reconnect |
Close too_slow | Reconnect immediately, process faster |
HTTP 429 | Back off (you hit a connection limit) |
| Any other disconnect | Reconnect with exponential backoff |
Detecting dead connections
The server sends a WebSocket PING every 15 s and closes the connection if no PONG arrives within 30 s. Your library handles PONG automatically and surfaces the disconnect as a close event — when the server is there to close it.
The case that bites: the server disappears without saying goodbye (host stopped or migrated, public IP moved, path cut). No FIN, no RST, no PING — nothing arrives, and nothing tells your socket it is dead. A client that only reads stays “connected” indefinitely and misses every announcement from then on. The rule is therefore: no frame of any kind (PING or heartbeat) for 60 s = dead connection → close and reconnect. A healthy connection carries a frame at least every 15 s, so 60 s is four missed server pings, far from a false positive.
| Library | What to do |
|---|---|
Python websockets | Nothing: the default ping_interval=20, ping_timeout=20 sends client pings and raises ConnectionClosed when pongs stop. Keep those defaults. |
Node.js ws | ws does not ping by itself. Reset a 60 s timer on every 'ping' and 'message'; when it fires, ws.terminate() and let your reconnect logic run. |
Go gorilla/websocket | SetReadDeadline(now + 60 s) and push it forward in the ping handler and on every message. |
Rust tokio-tungstenite | A tokio::time::interval in your select! that checks the last-received instant and breaks out of the read loop past 60 s. |
Enable TCP keepalive on the socket too if your stack lets you (SO_KEEPALIVE, e.g. 30 s idle / 10 s probes): the kernel then detects a vanished peer even if the application does nothing.
import asyncio, json, websockets
async def stream(ws):
try:
async for raw in ws:
data = json.loads(raw)
if data["type"] == "announcement":
handle(data)
except websockets.ConnectionClosed as e:
print(f"closed code={e.code} reason={e.reason!r}")
Don’t confuse the two silences. “No announcement for N seconds” is normal — listings are sparse, never reconnect on that. “No frame at all (PING, heartbeat) for 60 s” is a dead connection — always reconnect on that, whether or not your library reported a close.