Auto-reconnect on WebSocket disconnect

- Extract connection logic into connectAndAuth() so it can be called
  on both initial connect and reconnect
- Add ws.onclose handler: if not an intentional disconnect, clear stale
  intervals and retry connectAndAuth() after 5 seconds
- Track sync, heartbeat, and tokenRenewal interval handles so they are
  cleared and recreated cleanly on each reconnect
- Reset syncComplete = false on reconnect so the scheduler waits for
  a fresh sync before trading
- disconnect() sets intentionalDisconnect = true and clears all intervals
  to prevent reconnect loops when a client is removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Senofy
2026-03-12 23:21:03 -05:00
co-authored by Claude Sonnet 4.6
parent d0065ab047
commit 15ef2d7adc
+35 -9
View File
@@ -36,6 +36,12 @@ export class TradovateClient {
public fetchDaysComplete = false; public fetchDaysComplete = false;
/** NodeJS.Timeout handle for the hourly dailyPnL refresh */ /** NodeJS.Timeout handle for the hourly dailyPnL refresh */
private daysFetchInterval: ReturnType<typeof setInterval> | null = null; private daysFetchInterval: ReturnType<typeof setInterval> | null = null;
/** Interval handles tracked so they can be cleared on reconnect */
private syncInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
/** Set to true by disconnect() to suppress reconnect on close */
private intentionalDisconnect = false;
/** Last error per account name from fetchDaysTraded() */ /** Last error per account name from fetchDaysTraded() */
public lastFetchErrors: Record<string, string> = {}; public lastFetchErrors: Record<string, string> = {};
/** Raw reports API response sample per account (first 100 chars) for debugging */ /** Raw reports API response sample per account (first 100 chars) for debugging */
@@ -87,7 +93,10 @@ export class TradovateClient {
this.name = name; this.name = name;
this.password = password; this.password = password;
this.callbackOnSyncRequest = callbackOnSyncRequest; this.callbackOnSyncRequest = callbackOnSyncRequest;
this.connectAndAuth();
}
private connectAndAuth(): void {
this.login().then((res) => { this.login().then((res) => {
if (!res?.accessToken) { if (!res?.accessToken) {
console.log('Failed to login', res); console.log('Failed to login', res);
@@ -95,19 +104,25 @@ export class TradovateClient {
} }
console.log('Logged in', res); console.log('Logged in', res);
this.accessInfo = res;
// check every 2 minutes if the access token is expired // Clear and restart token renewal interval
setInterval(() => { if (this.tokenRenewalInterval) clearInterval(this.tokenRenewalInterval);
// If we are within 15 minutes of the expiration time, renew the access token this.tokenRenewalInterval = setInterval(() => {
if ( if (
new Date(res.expirationTime).getTime() < new Date(this.accessInfo.expirationTime).getTime() <
new Date().getTime() + 15 * 60 * 1000 new Date().getTime() + 15 * 60 * 1000
) { ) {
this.renewAccessToken(); this.renewAccessToken();
} }
}, 2 * 60 * 1000); }, 2 * 60 * 1000);
this.accessInfo = res; // Clear stale WS intervals before opening a new connection
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
// Reset sync state so scheduler waits for the new sync to complete
this.syncComplete = false;
const randomnumber = Math.random().toString(36).substring(2, 15); const randomnumber = Math.random().toString(36).substring(2, 15);
this.ws = new WebSocket(`wss://demo.tradovateapi.com/v1/websocket?r=${randomnumber}`); this.ws = new WebSocket(`wss://demo.tradovateapi.com/v1/websocket?r=${randomnumber}`);
@@ -117,10 +132,10 @@ export class TradovateClient {
this.ws.send('authorize\n2\n\n' + this.accessInfo.accessToken); this.ws.send('authorize\n2\n\n' + this.accessInfo.accessToken);
this.directEventCallbacks[2] = (response: any) => { this.directEventCallbacks[2] = (response: any) => {
this.requestSync(); this.requestSync();
setInterval(() => this.requestSync(), 60000); this.syncInterval = setInterval(() => this.requestSync(), 60000);
// Every 2.5 seconds send a heartbeat // Every 2.5 seconds send a heartbeat
setInterval(() => { this.heartbeatInterval = setInterval(() => {
this.ws.send('[]'); this.ws.send('[]');
}, 2500); }, 2500);
}; };
@@ -178,8 +193,6 @@ export class TradovateClient {
if (this.recentEntityEvents.length > 50) this.recentEntityEvents.shift(); if (this.recentEntityEvents.length > 50) this.recentEntityEvents.shift();
} }
// Update positions from WebSocket position events // Update positions from WebSocket position events
if (response.d?.entityType === 'position' && response.d?.entity) { if (response.d?.entityType === 'position' && response.d?.entity) {
const pos = response.d.entity; const pos = response.d.entity;
@@ -215,6 +228,14 @@ export class TradovateClient {
this.ws.onerror = (event) => { this.ws.onerror = (event) => {
console.error('Error on websocket', event); console.error('Error on websocket', event);
}; };
this.ws.onclose = (event) => {
if (this.intentionalDisconnect) return;
console.warn(`[tradovate] WebSocket closed (code=${event.code}) — reconnecting in 5s`);
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
setTimeout(() => this.connectAndAuth(), 5_000);
};
}); });
} }
@@ -700,6 +721,11 @@ export class TradovateClient {
/** Close the WebSocket connection and stop all intervals. Call before discarding the instance. */ /** Close the WebSocket connection and stop all intervals. Call before discarding the instance. */
public disconnect(): void { public disconnect(): void {
this.intentionalDisconnect = true;
if (this.syncInterval) { clearInterval(this.syncInterval); this.syncInterval = null; }
if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = null; }
if (this.tokenRenewalInterval) { clearInterval(this.tokenRenewalInterval); this.tokenRenewalInterval = null; }
if (this.daysFetchInterval) { clearInterval(this.daysFetchInterval); this.daysFetchInterval = null; }
try { this.ws?.close(); } catch { /* ignore */ } try { this.ws?.close(); } catch { /* ignore */ }
} }