feat: retry failed alert deliveries

This commit is contained in:
2026-07-19 12:58:53 +08:00
parent 00928b72e4
commit 1e01f3295e
9 changed files with 74 additions and 9 deletions
+30 -3
View File
@@ -5,7 +5,34 @@ function allowed(bucket: string, seconds = 3600) {
if (row && row.reset_at > now) return false;
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,1,?) ON CONFLICT(bucket) DO UPDATE SET count=count+1,reset_at=excluded.reset_at").run(bucket, now + seconds); return true;
}
export async function sendAlert(bucket: string, title: string, message: string) {
const url = process.env.ALERT_WEBHOOK_URL?.trim(); if (!url || !allowed(`alert:${bucket}`)) return false;
try { const isNtfy = /(^|\.)ntfy\.sh\//.test(new URL(url).hostname + new URL(url).pathname); const response = await fetch(url, isNtfy ? { method: "POST", headers: { Title: title, Priority: "high" }, body: message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${title}**\n${message}` }), signal: AbortSignal.timeout(10_000) }); if (!response.ok) throw new Error(`Alert ${response.status}`); return true; } catch { return false; }
export function sendAlert(bucket: string, title: string, message: string) {
if (!process.env.ALERT_WEBHOOK_URL?.trim() || !allowed(`alert:${bucket}`)) return false;
db.prepare("INSERT INTO alert_deliveries(bucket,title,message) VALUES(?,?,?)").run(bucket, title, message);
return true;
}
type Alert = { id: number; title: string; message: string; attempts: number };
export async function deliverNextAlert() {
const candidate = db.prepare("SELECT id FROM alert_deliveries WHERE status='queued' AND run_after<=CURRENT_TIMESTAMP ORDER BY id LIMIT 1").get() as { id: number } | undefined;
if (!candidate) return false;
const claimed = db.prepare("UPDATE alert_deliveries SET status='running',attempts=attempts+1,started_at=CURRENT_TIMESTAMP WHERE id=? AND status='queued'").run(candidate.id);
if (!claimed.changes) return false;
const alert = db.prepare("SELECT id,title,message,attempts FROM alert_deliveries WHERE id=?").get(candidate.id) as Alert;
const url = process.env.ALERT_WEBHOOK_URL?.trim();
try {
if (!url) throw new Error("ALERT_WEBHOOK_URL is not configured");
const target = new URL(url);
if (target.protocol !== "https:") throw new Error("Alert webhook must use HTTPS");
const isNtfy = /(^|\.)ntfy\.sh$/.test(target.hostname);
const response = await fetch(target, isNtfy ? { method: "POST", headers: { Title: alert.title, Priority: "high" }, body: alert.message, signal: AbortSignal.timeout(10_000) } : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content: `**${alert.title}**\n${alert.message}` }), signal: AbortSignal.timeout(10_000) });
if (!response.ok) throw new Error(`Alert endpoint returned ${response.status}`);
db.prepare("UPDATE alert_deliveries SET status='done',finished_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(alert.id);
} catch (error) {
const message = error instanceof Error ? error.message : "Alert delivery failed";
const exhausted = alert.attempts >= 5;
const delayMinutes = Math.min(60, 2 ** Math.max(0, alert.attempts - 1));
db.prepare("UPDATE alert_deliveries SET status=?,last_error=?,finished_at=CASE WHEN ? THEN CURRENT_TIMESTAMP ELSE NULL END,run_after=CASE WHEN ? THEN run_after ELSE datetime('now', ?) END WHERE id=?").run(exhausted ? "failed" : "queued", message, exhausted ? 1 : 0, exhausted ? 1 : 0, `+${delayMinutes} minutes`, alert.id);
}
return true;
}