mirror of
https://github.com/tiennm99/claude-status-webhook.git
synced 2026-04-17 15:20:37 +00:00
fix: harden webhook reliability, fix bugs, add test suite
- Statuspage webhook always returns 200 to prevent subscriber removal - Fix parseKvKey returning string chatId instead of number - Queue consumer retries on Telegram 5xx instead of acking (prevents message loss) - Fix observability top-level enabled flag (false → true) - Add defensive null checks for webhook payload body - Cache Bot instance per isolate to avoid middleware rebuild per request - Add vitest + @cloudflare/vitest-pool-workers with 31 tests - Document DLQ and KV sharding as declined features
This commit is contained in:
@@ -34,53 +34,73 @@ function formatComponentMessage(component, update) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming Statuspage webhook
|
||||
* Handle incoming Statuspage webhook.
|
||||
* CRITICAL: Always return 200 — Statuspage removes subscriber webhooks on non-2xx responses.
|
||||
*/
|
||||
export async function handleStatuspageWebhook(c) {
|
||||
// Validate URL secret (timing-safe)
|
||||
const secret = c.req.param("secret");
|
||||
if (!await timingSafeEqual(secret, c.env.WEBHOOK_SECRET)) {
|
||||
return c.text("Unauthorized", 401);
|
||||
}
|
||||
|
||||
// Parse body
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return c.text("Bad Request", 400);
|
||||
// Validate URL secret (timing-safe)
|
||||
const secret = c.req.param("secret");
|
||||
if (!await timingSafeEqual(secret, c.env.WEBHOOK_SECRET)) {
|
||||
console.error("Statuspage webhook: invalid secret");
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
// Parse body
|
||||
let body;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
console.error("Statuspage webhook: invalid JSON body");
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
const eventType = body?.meta?.event_type;
|
||||
if (!eventType) {
|
||||
console.error("Statuspage webhook: missing event_type");
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
console.log(`Statuspage webhook: ${eventType}`);
|
||||
|
||||
// Determine category and format message
|
||||
let category, html, componentName;
|
||||
if (eventType.startsWith("incident.")) {
|
||||
if (!body.incident) {
|
||||
console.error("Statuspage webhook: incident event missing incident data");
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
category = "incident";
|
||||
html = formatIncidentMessage(body.incident);
|
||||
} else if (eventType.startsWith("component.")) {
|
||||
if (!body.component) {
|
||||
console.error("Statuspage webhook: component event missing component data");
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
category = "component";
|
||||
componentName = body.component.name || null;
|
||||
html = formatComponentMessage(body.component, body.component_update);
|
||||
} else {
|
||||
console.error(`Statuspage webhook: unknown event type ${eventType}`);
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
// Get filtered subscribers (with component name filtering)
|
||||
const subscribers = await getSubscribersByType(c.env.claude_status, category, componentName);
|
||||
|
||||
// Enqueue messages for fan-out via CF Queues (batch for performance)
|
||||
const messages = subscribers.map(({ chatId, threadId }) => ({
|
||||
body: { chatId, threadId, html },
|
||||
}));
|
||||
for (let i = 0; i < messages.length; i += 100) {
|
||||
await c.env["claude-status"].sendBatch(messages.slice(i, i + 100));
|
||||
}
|
||||
|
||||
console.log(`Enqueued ${messages.length} messages for ${category}${componentName ? `:${componentName}` : ""}`);
|
||||
return c.text("OK", 200);
|
||||
} catch (err) {
|
||||
// Catch-all: log error but still return 200 to prevent Statuspage from removing us
|
||||
console.error("Statuspage webhook: unexpected error", err);
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
const eventType = body?.meta?.event_type;
|
||||
if (!eventType) return c.text("Bad Request", 400);
|
||||
|
||||
console.log(`Statuspage webhook: ${eventType}`);
|
||||
|
||||
// Determine category and format message
|
||||
let category, html, componentName;
|
||||
if (eventType.startsWith("incident.")) {
|
||||
category = "incident";
|
||||
html = formatIncidentMessage(body.incident);
|
||||
} else if (eventType.startsWith("component.")) {
|
||||
category = "component";
|
||||
componentName = body.component?.name || null;
|
||||
html = formatComponentMessage(body.component, body.component_update);
|
||||
} else {
|
||||
return c.text("Unknown event type", 400);
|
||||
}
|
||||
|
||||
// Get filtered subscribers (with component name filtering)
|
||||
const subscribers = await getSubscribersByType(c.env.claude_status, category, componentName);
|
||||
|
||||
// Enqueue messages for fan-out via CF Queues (batch for performance)
|
||||
const messages = subscribers.map(({ chatId, threadId }) => ({
|
||||
body: { chatId, threadId, html },
|
||||
}));
|
||||
for (let i = 0; i < messages.length; i += 100) {
|
||||
await c.env["claude-status"].sendBatch(messages.slice(i, i + 100));
|
||||
}
|
||||
|
||||
console.log(`Enqueued ${messages.length} messages for ${category}${componentName ? `:${componentName}` : ""}`);
|
||||
|
||||
return c.text("OK", 200);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user