mirror of
https://github.com/discountry/ritmex-bot.git
synced 2026-09-09 08:18:07 +00:00
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.
170 lines
5.2 KiB
TypeScript
170 lines
5.2 KiB
TypeScript
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
ReconnectScheduler,
|
|
exponentialBackoff,
|
|
fixedBackoff,
|
|
linearBackoff,
|
|
} from "../src/exchanges/reconnect-scheduler";
|
|
|
|
describe("backoff policies", () => {
|
|
it("fixed returns the same delay every attempt", () => {
|
|
const policy = fixedBackoff(3000);
|
|
expect([1, 2, 5].map(policy)).toEqual([3000, 3000, 3000]);
|
|
});
|
|
|
|
it("exponential doubles from the base and caps", () => {
|
|
const policy = exponentialBackoff(1000, 8000);
|
|
expect([1, 2, 3, 4, 5].map(policy)).toEqual([1000, 2000, 4000, 8000, 8000]);
|
|
});
|
|
|
|
it("linear grows by the base and caps", () => {
|
|
const policy = linearBackoff(2000, 30_000);
|
|
expect([1, 2, 3, 20].map(policy)).toEqual([2000, 4000, 6000, 30_000]);
|
|
});
|
|
});
|
|
|
|
describe("ReconnectScheduler", () => {
|
|
beforeEach(() => vi.useFakeTimers());
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("reconnects after the backoff delay", async () => {
|
|
const connect = vi.fn();
|
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
|
|
|
|
scheduler.schedule();
|
|
expect(connect).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(connect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("collapses repeated schedule() calls into one pending attempt", async () => {
|
|
const connect = vi.fn();
|
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(1000) });
|
|
|
|
scheduler.schedule();
|
|
scheduler.schedule();
|
|
scheduler.schedule();
|
|
expect(scheduler.pending).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(connect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("grows the delay across consecutive failures", async () => {
|
|
const delays: number[] = [];
|
|
const scheduler = new ReconnectScheduler({
|
|
connect: async () => {
|
|
throw new Error("refused");
|
|
},
|
|
backoff: exponentialBackoff(1000, 60_000),
|
|
onSchedule: (delay) => delays.push(delay),
|
|
});
|
|
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await vi.advanceTimersByTimeAsync(4000);
|
|
expect(delays.slice(0, 3)).toEqual([1000, 2000, 4000]);
|
|
});
|
|
|
|
it("resets the backoff once the socket opens", async () => {
|
|
const delays: number[] = [];
|
|
let failing = true;
|
|
const scheduler = new ReconnectScheduler({
|
|
connect: async () => {
|
|
if (failing) throw new Error("refused");
|
|
},
|
|
backoff: exponentialBackoff(1000, 60_000),
|
|
onSchedule: (delay) => delays.push(delay),
|
|
});
|
|
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(scheduler.attemptCount).toBe(2);
|
|
|
|
// A successful open must clear the counter, or the next transient blip
|
|
// would wait as long as the last outage did.
|
|
failing = false;
|
|
scheduler.onConnected();
|
|
expect(scheduler.attemptCount).toBe(0);
|
|
|
|
delays.length = 0;
|
|
scheduler.schedule();
|
|
expect(delays[0]).toBe(1000);
|
|
});
|
|
|
|
it("reports a synchronous connect failure and retries", async () => {
|
|
const errors: unknown[] = [];
|
|
let calls = 0;
|
|
const scheduler = new ReconnectScheduler({
|
|
connect: () => {
|
|
calls += 1;
|
|
if (calls === 1) throw new Error("boom");
|
|
},
|
|
backoff: fixedBackoff(500),
|
|
onError: (error) => errors.push(error),
|
|
});
|
|
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(errors).toHaveLength(1);
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(calls).toBe(2);
|
|
});
|
|
|
|
it("honours shouldReconnect", async () => {
|
|
const connect = vi.fn();
|
|
let running = false;
|
|
const scheduler = new ReconnectScheduler({
|
|
connect,
|
|
backoff: fixedBackoff(100),
|
|
shouldReconnect: () => running,
|
|
});
|
|
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(connect).not.toHaveBeenCalled();
|
|
|
|
running = true;
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(connect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("cancel() drops the pending attempt but keeps the scheduler usable", async () => {
|
|
const connect = vi.fn();
|
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
|
|
|
scheduler.schedule();
|
|
scheduler.cancel();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(connect).not.toHaveBeenCalled();
|
|
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
expect(connect).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("stop() is permanent", async () => {
|
|
const connect = vi.fn();
|
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
|
|
|
scheduler.schedule();
|
|
scheduler.stop();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
scheduler.schedule();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(connect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not reconnect when the timer fires after stop()", async () => {
|
|
const connect = vi.fn();
|
|
const scheduler = new ReconnectScheduler({ connect, backoff: fixedBackoff(100) });
|
|
|
|
scheduler.schedule();
|
|
scheduler.stop();
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
expect(connect).not.toHaveBeenCalled();
|
|
});
|
|
});
|