refactor(exchanges): extract ReconnectScheduler from four gateways

standx, ondoperps, lighter and backpack each hand-rolled the same three pieces
around their socket: a 'one pending attempt at a time' timer guard, an attempt
counter feeding a backoff formula, and resetting that counter on open. Four
field-name conventions, four formulas, four places to forget the reset.

ReconnectScheduler owns the timer, the guard and the counter; the backoff stays
a caller-supplied policy (fixed / linear / exponential), since the venues
genuinely want different curves. Verified value-by-value that all four produce
the identical delay sequence they did before.

Scope was chosen, not maximal. The other five gateways' reconnect paths, and all
nine connect/auth/subscribe/heartbeat flows, stay where they are: those are
60-200 lines of per-venue protocol, and folding nine different auth and ping
semantics into one base class would be the wrong abstraction. I also checked the
reconnect paths for the classic defects — aster's timer that looked unclearable
is cleared at the top of connect(), and all three backoff counters do reset on
open — so there was no bug to fix here, only duplication.

12 new tests. 287 pass; tsc and oxlint clean.
This commit is contained in:
discountry
2026-07-29 21:40:09 +08:00
parent 22b9c5a39d
commit 0ea71503e3
6 changed files with 322 additions and 79 deletions
+15 -24
View File
@@ -1,5 +1,6 @@
import { setInterval, clearInterval, setTimeout, clearTimeout } from "timers";
import WebSocket from "ws";
import { ReconnectScheduler, linearBackoff } from "../reconnect-scheduler";
import type {
AccountListener,
DepthListener,
@@ -150,6 +151,8 @@ const BALANCE_EPSILON = 1e-9;
const KLINE_DEFAULT_COUNT = 120;
const DEFAULT_TICKER_POLL_MS = 3000;
const DEFAULT_KLINE_POLL_MS = 15000;
const WS_RECONNECT_BASE_MS = 2_000;
const WS_RECONNECT_MAX_MS = 30_000;
const WS_HEARTBEAT_INTERVAL_MS = 5_000;
const CLIENT_PING_INTERVAL_MS = 2_000;
const WS_STALE_TIMEOUT_MS = 20_000;
@@ -246,8 +249,14 @@ export class LighterGateway {
private readonly orderIndexByClientId = new Map<string, string>();
private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private reconnectAttempts = 0;
private readonly reconnect: ReconnectScheduler = new ReconnectScheduler({
connect: async () => {
await this.openWebSocket();
this.reconnect.onConnected();
},
backoff: linearBackoff(WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS),
onError: (error) => this.logger("reconnect", error),
});
private readonly wsUrl: string;
private connectPromise: Promise<void> | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
@@ -759,7 +768,7 @@ export class LighterGateway {
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.reconnect.schedule();
});
ws.on("error", (error) => {
this.logger("ws:error", error);
@@ -769,7 +778,7 @@ export class LighterGateway {
return;
}
this.stopStaleMonitor();
this.scheduleReconnect();
this.reconnect.schedule();
});
});
}
@@ -945,24 +954,6 @@ export class LighterGateway {
return candidates[0] ?? null;
}
private scheduleReconnect(): void {
if (this.reconnectTimer) return;
const attempt = this.reconnectAttempts + 1;
const delay = Math.min(2000 * attempt, 30_000);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.openWebSocket()
.then(() => {
this.reconnectAttempts = 0;
})
.catch((error) => {
this.logger("reconnect", error);
this.reconnectAttempts = attempt;
this.scheduleReconnect();
});
}, delay);
}
private forceReconnect(reason: string): void {
const now = Date.now();
if (this.staleReason && now - this.lastDepthUpdateAt < FEED_STALE_TIMEOUT_MS / 2) {
@@ -978,7 +969,7 @@ export class LighterGateway {
}
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
this.reconnect.schedule();
}
private startHeartbeat(): void {
@@ -995,7 +986,7 @@ export class LighterGateway {
} finally {
this.stopHeartbeat();
this.stopClientPing();
this.scheduleReconnect();
this.reconnect.schedule();
}
return;
}