diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 03b40e0..237ae64 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,15 @@ import { channels } from "./routes/channels"; import { migrate } from "./db"; import { SECURITY_HEADERS } from "../../shared/auth"; +// Global safety net: never let an unhandled rejection take down the API process. +// We'd rather log loudly and stay up than 502 every monitor on a single bug. +process.on("unhandledRejection", (reason) => { + console.error("[unhandledRejection]", reason); +}); +process.on("uncaughtException", (err) => { + console.error("[uncaughtException]", err); +}); + await migrate(); const elysia = new Elysia() diff --git a/apps/api/src/notifications/index.ts b/apps/api/src/notifications/index.ts index 33be088..b70166c 100644 --- a/apps/api/src/notifications/index.ts +++ b/apps/api/src/notifications/index.ts @@ -26,14 +26,20 @@ export async function dispatch(channel: ChannelRow, event: NotificationEvent): P } export async function dispatchForMonitor(monitorId: string, event: NotificationEvent): Promise { - const channels = await sql` - SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled - FROM notification_channels c - JOIN monitor_notifications mn ON mn.channel_id = c.id - WHERE mn.monitor_id = ${monitorId} AND c.enabled = true - `; - if (channels.length === 0) return; - await Promise.all(channels.map((c) => dispatch(c, event))); + // Belt-and-braces: this is called fire-and-forget from the ingest path, so any + // error escaping here would become an unhandled rejection and crash the process. + try { + const channels = await sql` + SELECT c.id, c.account_id, c.name, c.kind, c.config, c.enabled + FROM notification_channels c + JOIN monitor_notifications mn ON mn.channel_id = c.id + WHERE mn.monitor_id = ${monitorId} AND c.enabled = true + `; + if (channels.length === 0) return; + await Promise.all(channels.map((c) => dispatch(c, event))); + } catch (e) { + console.warn(`[notify] dispatchForMonitor failed for ${monitorId}:`, e); + } } export type { NotificationEvent, ChannelRow, MonitorContext, PingContext } from "./types"; diff --git a/apps/api/src/routes/pings.ts b/apps/api/src/routes/pings.ts index da6fa9a..287f9e9 100644 --- a/apps/api/src/routes/pings.ts +++ b/apps/api/src/routes/pings.ts @@ -169,11 +169,11 @@ export const ingest = new Elysia() : { kind: "down", monitor: monitorCtx, ping: pingCtx }; // Suppress the synthetic "up" on a brand-new monitor (no prior state). if (!(body.up && prevState === null)) { - void dispatchForMonitor(monitor_check.id, event); + dispatchForMonitor(monitor_check.id, event).catch((e) => console.warn("[notify] dispatch escaped:", e)); } } if (certEvent && days != null) { - void dispatchForMonitor(monitor_check.id, { kind: "cert", monitor: monitorCtx, days }); + dispatchForMonitor(monitor_check.id, { kind: "cert", monitor: monitorCtx, days }).catch((e) => console.warn("[notify] cert dispatch escaped:", e)); } return { ok: true };