fix: exponential back-off reconnect + onClose/onError callbacks + autoReconnect ?? fix - #46
fix: exponential back-off reconnect + onClose/onError callbacks + autoReconnect ?? fix#46osr21 wants to merge 8 commits into
Conversation
…econnect ?? fix Closes three related bugs: 1. autoReconnect: false silently ignored — || treated false as falsy. Fixed with ?? (nullish coalescing). 2. No onClose / onError callbacks exposed to callers. Added both to RealTimeDataClientArgs and wired into handlers. 3. Tight reconnect loop causes heap OOM on persistent network errors (Polymarket#38). Replaced immediate this.connect() with scheduleReconnect() which uses exponential back-off with ±1 s jitter: delay = min(reconnectDelay × 2^attempt, maxReconnectDelay) + rand(0..1000) ms Defaults: 1 s initial, 30 s cap — both configurable via RealTimeDataClientArgs. Bonus: - connectionId counter prevents double-reconnect when onError + onClose both fire on the same closed socket. - disconnect() clears the pending reconnect timer and guards against calling close() on an already-CLOSED/CLOSING socket. - Constructor uses optional chaining (args?.) so new RealTimeDataClient() with no args no longer throws on the args! non-null assertion.
…nId check, clear timer in connect() Fixes 3 issues flagged in code review: 1. [High] Stale close updates connection status onClose and onError were always running notifyStatusChange(DISCONNECTED) and user callbacks regardless of whether the event came from the current socket or a superseded one. A late event from an old WebSocket could falsely mark the client DISCONNECTED while the new socket was healthy. Fix: early-return at the top of onClose and onError when id !== connectionId. 2. [Medium] Stale open resets reconnect state onOpen received the per-connect() id but never compared it to connectionId. A delayed 'open' from a superseded socket would reset reconnectAttempt, start a duplicate ping loop, notify CONNECTED, and invoke onConnect against the wrong lifecycle. Fix: early-return at the top of onOpen when id !== connectionId. 3. [Medium] Pending reconnect timer not cleared in connect() Calling connect() manually while a back-off timer was already scheduled left the timer alive; when it fired it opened a second concurrent socket. Fix: clear reconnectTimer at the start of connect() before creating the socket.
|
Thanks for the review @cursor[bot] — all three issues are addressed in commit e87b58a. |
… duplicate DISCONNECTED events 1. [High] Reconnect after disconnect race scheduleReconnect()'s timer callback called connect() unconditionally. clearTimeout() cannot cancel a callback already in the task queue, so calling disconnect() right as the timer fires still opened a new socket. Fix: re-check this.autoReconnect inside the setTimeout callback as a last-resort guard after the null-timer check. 2. [Low] Duplicate DISCONNECTED status events onError was emitting notifyStatusChange(DISCONNECTED) and then onClose also emitted it, causing onStatusChange to fire twice for one drop. WebSocket guarantees onClose always follows onError on the same socket, so DISCONNECTED is now emitted only from onClose. onError is reduced to logging and invoking the user onError callback.
|
Both issues from the latest review are addressed in commit 7ff91d6. |
…ack, backoff counter survives disconnect 1. [High] Duplicate reconnect after onClose callback onClose called scheduleReconnect() unconditionally after invoking the user's onClose callback. But the user callback may itself call connect() to reconnect immediately (e.g. custom reconnect logic). If it did, connectionId was already incremented, yet scheduleReconnect() still ran and opened a second socket. Fix: re-check id === this.connectionId after the user callback returns. If the callback called connect(), connectionId has advanced and we skip scheduleReconnect(), preventing the duplicate socket. 2. [Medium] Backoff counter survives disconnect reconnectAttempt was only reset on a successful onOpen. After many failed auto-reconnects, calling disconnect() and later reconnecting manually inherited the stale exponent, so the first failure after re-connect waited up to maxReconnectDelay instead of the initial reconnectDelay. Fix: reset reconnectAttempt = 0 in disconnect() and in connect() when it cancels a pending timer (a manual connect overrides the back-off schedule and signals intent to start fresh).
|
Both issues from this review addressed in commit 40c9e9b. |
…vent resource leak Addresses Graphite review: calling connect() while a socket is still active abandoned the old socket on the network without closing it, leaking file descriptors and browser/server connection slots over time. Fix: explicitly close this.ws before assigning the new WebSocket, guarded by the same CLOSED/CLOSING readyState check used in disconnect(). Critical ordering note: connectionId is incremented BEFORE ws.close() is called. The old socket's onClose/onError handlers capture the previous id and early-return on the id !== connectionId guard, so the deliberate close does not emit a spurious DISCONNECTED notification or trigger scheduleReconnect(). Graphite's own suggested snippet closed the socket before incrementing connectionId, which would have caused exactly those side-effects.
|
Thanks for the catch @graphite-app — fixed in commit 3d06286. |
…chains Addresses Cursor Bugbot review (commit 3d06286): the onPong handler scheduled the next ping() via delay(...).then(() => this.ping()) with no reference to connectionId. When connect() replaced a socket, any pending delay from the old onPong chain resolved and called ping() against this.ws (the new socket), duplicating keepalive traffic on the current connection. Fix: onPong now receives the connection id captured at connect() time and passes it through to the delay callback. The callback checks id === connectionId before calling ping() — if the connection has been superseded the chain stops. To wire the id through cleanly, the pong handler is registered as this.ws.pong = (data: Buffer) => this.onPong(id) instead of the bare this.onPong assignment, matching the pattern already used for onOpen, onClose, and onError.
|
Fixed in commit aaa5384. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
Reviewed by Cursor Bugbot for commit aaa5384. Configure here.
…as advanced Addresses Cursor Bugbot review (commit aaa5384): when connect() ran before the prior socket's close event was handled, onClose returned immediately on the id !== connectionId guard — silently dropping the DISCONNECTED notification, the onUserClose callback, and the status update. This happened whenever: - onUserError (or onError) called connect() before close fired - connect() followed disconnect() before the close callback ran Root cause: the early-return guard conflated two independent questions: 1. Should we notify? (emit DISCONNECTED + invoke onUserClose) 2. Should we reconnect? (scheduleReconnect) Fix: introduce lastConnectedId, set to the socket's id inside onOpen. onClose now answers both questions separately: const wasConnected = id === this.lastConnectedId; // reached OPEN state const isCurrent = id === this.connectionId; // no replacement yet // Notify if the socket was ever connected — a CONNECTED was published // so a paired DISCONNECTED must follow, even if a new socket is already // opening. A socket replaced before it opened is silently dropped. if (!wasConnected && !isCurrent) return; notifyStatusChange(DISCONNECTED); onUserClose(...); // Reconnect only if this is still the current connection and the user // callback did not itself call connect(). if (autoReconnect && isCurrent && id === connectionId) scheduleReconnect();
|
Fixed in commit d7f0918. |
Addresses Graphite review (commit d7f0918): when a socket closed before onOpen fired (failed handshake, immediate network error), isCurrent was true so the guard passed and DISCONNECTED was emitted unconditionally. This produced CONNECTING → DISCONNECTED without CONNECTED in between, violating the state machine contract. Fix: gate notifyStatusChange(DISCONNECTED) and the onUserClose callback behind wasConnected (id === lastConnectedId). A socket that never reached CONNECTED published no status event for it, so no paired DISCONNECTED should follow. The reconnect logic remains gated on isCurrent only — a failed handshake is still a valid reason to retry, with or without CONNECTED. Resulting state machine guarantees: CONNECTING → CONNECTED → DISCONNECTED (normal session drop) CONNECTING → CONNECTED → CONNECTING (onUserClose calls connect()) CONNECTING → CONNECTING (failed handshake, retried silently) CONNECTING → <stopped> (failed handshake, autoReconnect off)
|
Thanks @graphite-app — both points addressed. |
|
The exponential back-off and A few notes from that experience that might be useful before merge: 1. The 2. Consider gating reconnect on close code Some close codes indicate a permanent server-side rejection (e.g. private onClose = (event: CloseEvent) => {
this.onCloseCallback?.(event);
const permanent = event.code >= 4000 && event.code <= 4003;
if (this.autoReconnect && !permanent) {
this.scheduleReconnect();
}
};3. Pre-launch topic detection Before the TWAP topics go live on Aug 4, subscribing to { "type": "error", "message": "topic not found: crypto_prices_twap_thirty" }Detecting this in Relation to #45 This PR is a strict superset of #45 (same |

Summary
Note
Medium Risk
Changes core WebSocket connection lifecycle and status semantics; behavior shifts for consumers relying on immediate reconnect or prior status ordering, though scope is limited to one client module.
Overview
Overhauls
RealTimeDataClientreconnection and lifecycle handling insrc/client.ts.Reconnect behavior: Immediate
connect()on error/close is replaced withscheduleReconnect()using exponential backoff (configurablereconnectDelay/maxReconnectDelay) plus jitter, addressing tight reconnect loops and heap growth on sustained outages.API & bugs: Adds optional
onCloseandonErrorcallbacks (with try/catch). FixesautoReconnect: falseby using??instead of|| true. Constructor uses optional chaining sonew RealTimeDataClient()without args is safe.Concurrency / state: Introduces
connectionIdandlastConnectedIdso stale socket events cannot double-reconnect, corrupt status (CONNECTING→DISCONNECTEDwithoutCONNECTED), or keep ping chains alive.onClosealone schedules reconnect and emitsDISCONNECTEDonly when the socket had reachedCONNECTED.connect()cancels pending timers, closes the previous socket, and resets backoff when overriding a scheduled retry;disconnect()clears timers and avoids closing already-closed sockets.Reviewed by Cursor Bugbot for commit 33052dc. Bugbot is set up for automated code reviews on this repo. Configure here.