diff options
| author | Adam Soltys <[email protected]> | 2026-02-06 16:55:45 +0000 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-06 16:55:45 +0000 |
| commit | 9089698e8df9d0c09c165c45eb6b05335a818699 (patch) | |
| tree | 592be0e8964857d7be5ca9baacb6143b29903b27 /lib/health.ts | |
| parent | 0ad6c9ec8a4f91e1d45af4a76330cc3ddcf177a2 (diff) | |
| download | coinos-server-9089698e8df9d0c09c165c45eb6b05335a818699.tar.gz coinos-server-9089698e8df9d0c09c165c45eb6b05335a818699.zip | |
health check
Diffstat (limited to 'lib/health.ts')
| -rw-r--r-- | lib/health.ts | 147 |
1 files changed, 147 insertions, 0 deletions
diff --git a/lib/health.ts b/lib/health.ts new file mode 100644 index 00000000..c2e268c6 --- /dev/null +++ b/lib/health.ts @@ -0,0 +1,147 @@ +import ln, { LightningUnavailableError } from "$lib/ln"; +import { err, l, warn } from "$lib/logging"; + +const HEALTH_CHECK_INTERVAL = 2 * 60 * 1000; // 2 minutes +const RPC_TIMEOUT = 30_000; // 30 seconds - fail fast if RPC hangs +const MAX_CONSECUTIVE_FAILURES = 3; + +let consecutiveFailures = 0; +let lastSuccessTime = Date.now(); +let lastFailureReason = ""; +let checkTimer: ReturnType<typeof setTimeout> | null = null; + +export function getHealthStatus() { + return { + consecutiveFailures, + lastSuccessTime, + lastFailureReason, + healthy: consecutiveFailures < MAX_CONSECUTIVE_FAILURES, + stalledCheck: checkTimer === null && consecutiveFailures > 0, + }; +} + +function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + promise.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); +} + +async function checkLightningHealth(): Promise<boolean> { + const startTime = Date.now(); + + try { + l("health check: testing lightning connection..."); + const info = await withTimeout(ln.getinfo(), RPC_TIMEOUT, "getinfo"); + + if (!info?.id) { + throw new Error("getinfo returned invalid response (no node id)"); + } + + const testLabel = `health_check_${Date.now()}`; + const invoice = await withTimeout( + ln.invoice({ + amount_msat: "1000msat", + label: testLabel, + description: "health check", + expiry: 60, + }), + RPC_TIMEOUT, + "invoice", + ); + + if (!invoice?.bolt11) { + throw new Error("invoice creation returned invalid response"); + } + + try { + await withTimeout( + ln.delinvoice({ label: testLabel, status: "unpaid" }), + RPC_TIMEOUT, + "delinvoice", + ); + } catch (e) { + // Ignore cleanup errors + } + + const duration = Date.now() - startTime; + l(`health check: passed in ${duration}ms, node: ${info.id.slice(0, 16)}...`); + + return true; + } catch (e: any) { + const duration = Date.now() - startTime; + const errorCode = e?.code ?? e?.errno ?? "unknown"; + const errorMsg = e?.message ?? String(e); + + err( + `health check: FAILED after ${duration}ms`, + `code=${errorCode}`, + `error=${errorMsg}`, + ); + + if (e instanceof LightningUnavailableError) { + err("health check: lightning RPC socket unavailable"); + } + + lastFailureReason = `${errorCode}: ${errorMsg}`; + return false; + } +} + +export async function runHealthCheck() { + checkTimer = null; + + try { + const healthy = await checkLightningHealth(); + + if (healthy) { + if (consecutiveFailures > 0) { + l( + `health check: recovered after ${consecutiveFailures} consecutive failures`, + ); + } + consecutiveFailures = 0; + lastSuccessTime = Date.now(); + lastFailureReason = ""; + } else { + consecutiveFailures++; + warn( + `health check: failure ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}`, + ); + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + err( + `health check: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, ` + + `last success was ${Math.round((Date.now() - lastSuccessTime) / 1000)}s ago`, + ); + err(`health check: last failure reason: ${lastFailureReason}`); + err("health check: exiting process to trigger container restart"); + + setTimeout(() => { + process.exit(1); + }, 1000); + return; + } + } + } catch (e: any) { + err("health check: unexpected error during health check", e?.message); + consecutiveFailures++; + } + + // Schedule next check + checkTimer = setTimeout(runHealthCheck, HEALTH_CHECK_INTERVAL); +} + +export function startHealthCheck() { + l( + `health check: starting (interval=${HEALTH_CHECK_INTERVAL / 1000}s, timeout=${RPC_TIMEOUT / 1000}s, max_failures=${MAX_CONSECUTIVE_FAILURES})`, + ); + // Run first check after a delay to let the app initialize + checkTimer = setTimeout(runHealthCheck, 30000); +} |
