Self-healing broker clients, per-firm scheduler isolation, eager boot
Why the autotrader sometimes broke after a weekend until `pm2 restart`: `next start` only logs uncaught errors, so nothing ever crashed, and a few paths could leave a client dead for the life of the process. Tradovate: a closed socket scheduled exactly one re-login, with no catch — one failure during weekend maintenance (or a rate-limit penalty returned as a 200 without a token) and the client never came back. connectAndAuth is now a single-flight loop that never throws: jittered backoff capped at 5 min, penalty tickets waited out, captcha demands backed off. A 15s watchdog abandons silent, never-authorized or stuck-closing sockets and stale syncs; renewal falls back to a fresh login; orders fail fast (OrderNotPlacedError) on a dead socket and time out instead of hanging; a bad sync answer no longer wipes accounts and positions. TopstepX: hub start/stop/invoke and every REST call are bounded; a wedged hub is discarded and rebuilt; a stuck sync is superseded after 3 min; re-login no longer tears down the sync loop; a watchdog re-logs when syncs go stale; a no-fill timeout cancels the order. Scheduler: one unready client used to hold every firm forever. It now holds the tick for a 10-minute grace, then trades the other firms without it. Per-firm try/catch in batch collection, one batch at a time, accounts marked in flight, legs un-counted only when the entry never reached the broker, and the scheduler starts before the first batch fires. Boot and supervision: instrumentation.ts boots the process at server start (it used to wait for the first dashboard request); a supervisor rebuilds a client that has been unhealthy for 20 min (not a TopstepX one holding a position); broker HTTP calls default to a 60s timeout; GET /api/health shows each client's liveness and the scheduler's last decision. Shared state the eager boot would otherwise split across bundles lives on globalThis. Logs no longer carry access tokens or whole AxiosErrors (which include the encoded Tradovate password). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6146031a38
commit
b932452e98
+28
-5
@@ -4,10 +4,12 @@
|
||||
*
|
||||
* Run by the "at log on" scheduled task. Safe to run by hand at any time.
|
||||
*
|
||||
* The warm-up is not cosmetic. getClients() in lib/clients.ts is lazily
|
||||
* bootstrapped — Tradovate clients, the contract resolver, the reporter and the
|
||||
* persisted-schedule resume all start inside it — so until something makes an
|
||||
* HTTP request the process sits idle and none of that happens.
|
||||
* The warm-up used to be what started trading: getClients() in lib/clients.ts
|
||||
* was only bootstrapped by the first HTTP request. Builds with
|
||||
* instrumentation.ts boot it when the server starts, so the warm-up is now a
|
||||
* readiness check — it waits for each instance to answer, loads the dashboard
|
||||
* state once, and logs the scheduler and /api/health status. (On an older
|
||||
* build it still does the bootstrapping, so it stays.)
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { ensurePythonDeps } from './ensure-python-deps.mjs';
|
||||
@@ -58,7 +60,7 @@ for (const instance of loadInstances()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log(`warming up ${instance.name} (this is what triggers the client bootstrap)`);
|
||||
log(`warming up ${instance.name}`);
|
||||
try {
|
||||
await fetch(`${base}/api/state`, { signal: AbortSignal.timeout(60_000) });
|
||||
} catch (err) {
|
||||
@@ -71,6 +73,27 @@ for (const instance of loadInstances()) {
|
||||
? `${instance.name}: scheduler resumed — ${status.action} ${status.symbol}`
|
||||
: `${instance.name}: scheduler is stopped`);
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
// One line of client health. Right after boot a client or two may still
|
||||
// be logging in, so this reports; it doesn't fail the run. Builds from
|
||||
// before /api/health answer 404 — nothing to say then.
|
||||
try {
|
||||
const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(10_000) });
|
||||
if (res.status !== 404) {
|
||||
const health = await res.json();
|
||||
if (!res.ok) {
|
||||
log(`${instance.name}: /api/health failed — ${health.error ?? `HTTP ${res.status}`}`);
|
||||
} else {
|
||||
// Firms without a client yet aren't failing to sync — same
|
||||
// rule as the route's own `ok` (and the updater's line).
|
||||
const clients = (Array.isArray(health.clients) ? health.clients : [])
|
||||
.filter((c) => c?.hasClient !== false);
|
||||
const waiting = clients.filter((c) => !c.ready).map((c) => c.firm);
|
||||
log(`${instance.name}: ${clients.length - waiting.length}/${clients.length} firm(s) ready`
|
||||
+ (waiting.length > 0 ? ` — not ready yet: ${waiting.join(', ')}` : ''));
|
||||
}
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user