feat(): upgrade base WS client with improvements from binance SDK, upgrade ws client with deferred promise enrichment

This commit is contained in:
tiagosiebler
2025-05-19 13:37:57 +01:00
parent c4cc09489f
commit 910f80a55b
4 changed files with 98 additions and 19 deletions

View File

@@ -413,7 +413,7 @@ export abstract class BaseWebsocketClient<
wsTopicRequests, wsTopicRequests,
}, },
); );
return; return isConnectionInProgress;
} }
// We're connected. Check if auth is needed and if already authenticated // We're connected. Check if auth is needed and if already authenticated
@@ -532,7 +532,11 @@ export abstract class BaseWebsocketClient<
/** /**
* Request connection to a specific websocket, instead of waiting for automatic connection. * Request connection to a specific websocket, instead of waiting for automatic connection.
*/ */
public async connect(wsKey: TWSKey): Promise<WSConnectedResult | undefined> { public async connect(
wsKey: TWSKey,
customUrl?: string | undefined,
throwOnError?: boolean,
): Promise<WSConnectedResult | undefined> {
try { try {
if (this.wsStore.isWsOpen(wsKey)) { if (this.wsStore.isWsOpen(wsKey)) {
this.logger.error( this.logger.error(
@@ -549,7 +553,7 @@ export abstract class BaseWebsocketClient<
'Refused to connect to ws, connection attempt already active', 'Refused to connect to ws, connection attempt already active',
{ ...WS_LOGGER_CATEGORY, wsKey }, { ...WS_LOGGER_CATEGORY, wsKey },
); );
return; return this.wsStore.getConnectionInProgressPromise(wsKey)?.promise;
} }
if ( if (
@@ -563,7 +567,7 @@ export abstract class BaseWebsocketClient<
this.wsStore.createConnectionInProgressPromise(wsKey, false); this.wsStore.createConnectionInProgressPromise(wsKey, false);
} }
const url = await this.getWsUrl(wsKey); const url = customUrl || (await this.getWsUrl(wsKey));
const ws = this.connectToWsUrl(url, wsKey); const ws = this.connectToWsUrl(url, wsKey);
this.wsStore.setWs(wsKey, ws); this.wsStore.setWs(wsKey, ws);
@@ -572,6 +576,10 @@ export abstract class BaseWebsocketClient<
} catch (err) { } catch (err) {
this.parseWsError('Connection failed', err, wsKey); this.parseWsError('Connection failed', err, wsKey);
this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!);
if (throwOnError) {
throw err;
}
} }
} }
@@ -590,6 +598,8 @@ export abstract class BaseWebsocketClient<
this.parseWsError('Websocket onWsError', event, wsKey); this.parseWsError('Websocket onWsError', event, wsKey);
ws.onclose = (event: any) => this.onWsClose(event, wsKey); ws.onclose = (event: any) => this.onWsClose(event, wsKey);
ws.wsKey = wsKey;
return ws; return ws;
} }
@@ -668,12 +678,18 @@ export abstract class BaseWebsocketClient<
this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING); this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING);
} }
this.logger.info('Reconnecting to websocket with delay...', {
...WS_LOGGER_CATEGORY,
wsKey,
connectionDelayMs,
});
if (this.wsStore.get(wsKey)?.activeReconnectTimer) { if (this.wsStore.get(wsKey)?.activeReconnectTimer) {
this.clearReconnectTimer(wsKey); this.clearReconnectTimer(wsKey);
} }
this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => { this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => {
this.logger.info('Reconnecting to websocket', { this.logger.info('Reconnecting to websocket now', {
...WS_LOGGER_CATEGORY, ...WS_LOGGER_CATEGORY,
wsKey, wsKey,
}); });
@@ -1250,6 +1266,10 @@ export abstract class BaseWebsocketClient<
); );
this.getWsStore().rejectAllDeferredPromises(wsKey, 'disconnected'); this.getWsStore().rejectAllDeferredPromises(wsKey, 'disconnected');
this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); this.setWsState(wsKey, WsConnectionStateEnum.INITIAL);
// This was an intentional close, delete all state for this connection, as if it never existed:
this.wsStore.delete(wsKey);
this.emit('close', { wsKey, event }); this.emit('close', { wsKey, event });
} }
} }

View File

@@ -47,9 +47,9 @@ export class WsStore<
private wsState: Record<string, WsStoredState<TWSTopicSubscribeEventArgs>> = private wsState: Record<string, WsStoredState<TWSTopicSubscribeEventArgs>> =
{}; {};
private logger: typeof DefaultLogger; private logger: DefaultLogger;
constructor(logger: typeof DefaultLogger) { constructor(logger: DefaultLogger) {
this.logger = logger || DefaultLogger; this.logger = logger || DefaultLogger;
} }
@@ -131,6 +131,10 @@ export class WsStore<
return wsConnection; return wsConnection;
} }
/**
* deferred promises
*/
getDeferredPromise<TSuccessResult = any>( getDeferredPromise<TSuccessResult = any>(
wsKey: WsKey, wsKey: WsKey,
promiseRef: string | DeferredPromiseRef, promiseRef: string | DeferredPromiseRef,
@@ -206,9 +210,15 @@ export class WsStore<
if (promise?.reject) { if (promise?.reject) {
this.logger.trace( this.logger.trace(
`rejectDeferredPromise(): rejecting ${wsKey}/${promiseRef}/${value}`, `rejectDeferredPromise(): rejecting ${wsKey}/${promiseRef}`,
value,
); );
promise.reject(value);
if (typeof value === 'string') {
promise.reject(new Error(value));
} else {
promise.reject(value);
}
} }
if (removeAfter) { if (removeAfter) {
@@ -252,6 +262,9 @@ export class WsStore<
} }
try { try {
this.logger.trace(
`rejectAllDeferredPromises(): rejecting ${wsKey}/${promiseRef}/${reason}`,
);
this.rejectDeferredPromise(wsKey, promiseRef, reason, true); this.rejectDeferredPromise(wsKey, promiseRef, reason, true);
} catch (e) { } catch (e) {
this.logger.error( this.logger.error(
@@ -339,6 +352,7 @@ export class WsStore<
setConnectionState(key: WsKey, state: WsConnectionStateEnum) { setConnectionState(key: WsKey, state: WsConnectionStateEnum) {
this.get(key, true).connectionState = state; this.get(key, true).connectionState = state;
this.get(key, true).connectionStateChangedAt = new Date();
} }
isConnectionState(key: WsKey, state: WsConnectionStateEnum): boolean { isConnectionState(key: WsKey, state: WsConnectionStateEnum): boolean {
@@ -355,6 +369,22 @@ export class WsStore<
this.isConnectionState(key, WsConnectionStateEnum.CONNECTING) || this.isConnectionState(key, WsConnectionStateEnum.CONNECTING) ||
this.isConnectionState(key, WsConnectionStateEnum.RECONNECTING); this.isConnectionState(key, WsConnectionStateEnum.RECONNECTING);
if (isConnectionInProgress) {
const wsState = this.get(key, true);
const stateLastChangedAt = wsState?.connectionStateChangedAt;
const stateChangedAtTimestamp = stateLastChangedAt?.getTime();
if (stateChangedAtTimestamp) {
const timestampNow = new Date().getTime();
const stateChangedTimeAgo = timestampNow - stateChangedAtTimestamp;
const stateChangeTimeout = 15000; // allow a max 15 second timeout since the last state change before assuming stuck;
if (stateChangedTimeAgo >= stateChangeTimeout) {
const msg = 'State change timed out, reconnect workflow stuck?';
this.logger.error(msg, { key, wsState });
this.setConnectionState(key, WsConnectionStateEnum.ERROR);
}
}
}
return isConnectionInProgress; return isConnectionInProgress;
} }
@@ -366,13 +396,14 @@ export class WsStore<
getTopicsByKey(): Record<string, Set<TWSTopicSubscribeEventArgs>> { getTopicsByKey(): Record<string, Set<TWSTopicSubscribeEventArgs>> {
const result: any = {}; const result: any = {};
for (const refKey in this.wsState) { for (const refKey in this.wsState) {
result[refKey] = this.getTopics(refKey as WsKey); result[refKey] = this.getTopics(refKey as WsKey);
} }
return result; return result;
} }
// Since topics are objects we can't rely on the set to detect duplicates
/** /**
* Find matching "topic" request from the store * Find matching "topic" request from the store
* @param key * @param key

View File

@@ -8,7 +8,7 @@ export enum WsConnectionStateEnum {
CLOSING = 3, CLOSING = 3,
RECONNECTING = 4, RECONNECTING = 4,
// ERROR_RECONNECTING = 5, // ERROR_RECONNECTING = 5,
// ERROR = 5, ERROR = 5,
} }
export interface DeferredPromise<TSuccess = any, TError = any> { export interface DeferredPromise<TSuccess = any, TError = any> {
@@ -26,6 +26,7 @@ export interface WsStoredState<TWSTopicSubscribeEvent extends string | object> {
ws?: WebSocket; ws?: WebSocket;
/** The current lifecycle state of the connection (enum) */ /** The current lifecycle state of the connection (enum) */
connectionState?: WsConnectionStateEnum; connectionState?: WsConnectionStateEnum;
connectionStateChangedAt?: Date;
/** A timer that will send an upstream heartbeat (ping) when it expires */ /** A timer that will send an upstream heartbeat (ping) when it expires */
activePingTimer?: ReturnType<typeof setTimeout> | undefined; activePingTimer?: ReturnType<typeof setTimeout> | undefined;
/** A timer tracking that an upstream heartbeat was sent, expecting a reply before it expires */ /** A timer tracking that an upstream heartbeat was sent, expecting a reply before it expires */

View File

@@ -396,21 +396,48 @@ export class WebsocketClient extends BaseWebsocketClient<
// Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events // Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events
const promiseRef = getPromiseRefForWSAPIRequest(requestEvent); const promiseRef = getPromiseRefForWSAPIRequest(requestEvent);
const deferredPromise = const deferredPromise = this.getWsStore().createDeferredPromise<
this.getWsStore().createDeferredPromise<TWSAPIResponse>( // eslint-disable-next-line @typescript-eslint/no-explicit-any
wsKey, TWSAPIResponse & { request: any }
promiseRef, >(wsKey, promiseRef, false);
false,
); // Enrich returned promise with request context for easier debugging
deferredPromise.promise
?.then((res) => {
if (!Array.isArray(res)) {
res.request = {
wsKey,
...signedEvent,
};
}
return res;
})
.catch((e) => {
if (typeof e === 'string') {
this.logger.error('unexpcted string', { e });
return e;
}
e.request = {
wsKey,
operation,
params: params,
};
// throw e;
return e;
});
this.logger.trace( this.logger.trace(
`sendWSAPIRequest(): sending raw request: ${JSON.stringify(signedEvent, null, 2)}`, `sendWSAPIRequest(): sending raw request: ${JSON.stringify(signedEvent, null, 2)}`,
); );
// Send event // Send event
this.tryWsSend(wsKey, JSON.stringify(signedEvent)); const throwExceptions = false;
this.tryWsSend(wsKey, JSON.stringify(signedEvent), throwExceptions);
this.logger.trace(`sendWSAPIRequest(): sent ${operation} event`); this.logger.trace(
`sendWSAPIRequest(): sent "${operation}" event with promiseRef(${promiseRef})`,
);
// Return deferred promise, so caller can await this call // Return deferred promise, so caller can await this call
return deferredPromise.promise!; return deferredPromise.promise!;