Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

webhook-delivery.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

webhook-delivery.tsBlame401 lines · 1 contributor
8405c43Claude1/**
2 * Reliable webhook delivery (migration 0056).
3 *
4 * Replaces the inline single-shot fetch in src/routes/webhooks.tsx with a
5 * durable pending-row queue. `enqueueWebhookDelivery()` precomputes the HMAC
6 * signature, inserts one row per (hook, event) into `webhook_deliveries`
7 * with `status='pending'` and `next_attempt_at=now()`, then kicks the
8 * worker. The worker picks claims rows whose `next_attempt_at <= now()` and
9 * attempts each POST.
10 *
11 * Retry schedule (after attempt #1 fires immediately):
12 * attempt 2 → +30s
13 * attempt 3 → +2m
14 * attempt 4 → +10m
15 * attempt 5 → +1h
16 * attempt 6 → +6h
17 * after attempt 6 → status='dead' (no further retries; row kept for ops)
18 *
19 * 2xx response → status='succeeded' + succeeded_at + last_status_code.
20 * Anything else (including network errors and timeouts) → counted as a
21 * failed attempt and rescheduled.
22 *
23 * Public surface:
24 * - enqueueWebhookDelivery({...}) — fire-and-forget queue insert
25 * - attemptDelivery(deliveryId) — single attempt (exported for tests)
26 * - drainPendingDeliveries() — drain a batch (exported for tests)
27 * - startWebhookDeliveryWorker() — background poll loop
28 * - MAX_ATTEMPTS, BACKOFF_MS — exported for tests
29 */
30
31import { and, asc, eq, lte, sql } from "drizzle-orm";
32import { db } from "../db";
33import { webhookDeliveries, webhooks } from "../db/schema";
34
35// ---------------------------------------------------------------------------
36// Tunables
37// ---------------------------------------------------------------------------
38
39/** How many attempts in total before a row goes to status='dead'. */
40export const MAX_ATTEMPTS = 6;
41
42/**
43 * Backoff schedule, indexed by the *next* attempt number we're scheduling.
44 * After attemptCount=N fails, we schedule attempt N+1 at now() + BACKOFF_MS[N].
45 * Index 0 is unused (attempt 1 is queued at now() by enqueue, not by retry).
46 */
47export const BACKOFF_MS: number[] = [
48 0, // [0] unused
49 30_000, // after attempt 1 fails → +30s for attempt 2
50 120_000, // after attempt 2 fails → +2m for attempt 3
51 600_000, // after attempt 3 fails → +10m for attempt 4
52 3_600_000, // after attempt 4 fails → +1h for attempt 5
53 21_600_000, // after attempt 5 fails → +6h for attempt 6
54];
55
56/** How often the worker scans for due pending rows. */
57const DEFAULT_POLL_INTERVAL_MS = 5_000;
58
59/** Cap on rows pulled in a single tick. */
60const BATCH_SIZE = 10;
61
62/** Per-delivery HTTP timeout. */
63const DELIVERY_TIMEOUT_MS = 10_000;
64
65/** Cap on stored last_error string. */
66const ERROR_CAP = 2_000;
67
68// ---------------------------------------------------------------------------
69// Signing
70// ---------------------------------------------------------------------------
71
72/**
73 * Compute the `sha256=<hex>` HMAC signature for a payload. Returns the empty
74 * string when the hook has no secret — caller should still POST but skip the
75 * `X-Gluecron-Signature` header.
76 */
77export async function computeSignature(
78 secret: string | null,
79 payloadJson: string
80): Promise<string> {
81 if (!secret) return "";
82 const encoder = new TextEncoder();
83 const key = await crypto.subtle.importKey(
84 "raw",
85 encoder.encode(secret),
86 { name: "HMAC", hash: "SHA-256" },
87 false,
88 ["sign"]
89 );
90 const signature = await crypto.subtle.sign(
91 "HMAC",
92 key,
93 encoder.encode(payloadJson)
94 );
95 return (
96 "sha256=" +
97 Array.from(new Uint8Array(signature))
98 .map((b) => b.toString(16).padStart(2, "0"))
99 .join("")
100 );
101}
102
103// ---------------------------------------------------------------------------
104// Enqueue
105// ---------------------------------------------------------------------------
106
107/**
108 * Insert one pending delivery row. Caller passes hook + event + payload; we
109 * snapshot the signature now so future schedule-time secret rotation can't
110 * silently invalidate in-flight retries. Returns the new row id, or null on
111 * insert failure (logged; never throws).
112 */
113export async function enqueueWebhookDelivery(input: {
114 webhookId: string;
115 secret: string | null;
116 event: string;
117 payload: Record<string, unknown>;
118}): Promise<string | null> {
119 try {
120 const payloadJson = JSON.stringify(input.payload);
121 const signature = await computeSignature(input.secret, payloadJson);
122
123 const [row] = await db
124 .insert(webhookDeliveries)
125 .values({
126 webhookId: input.webhookId,
127 event: input.event,
128 payload: payloadJson,
129 signature,
130 attemptCount: 0,
131 nextAttemptAt: new Date(),
132 status: "pending",
133 })
134 .returning({ id: webhookDeliveries.id });
135
136 return row?.id ?? null;
137 } catch (err) {
138 console.error("[webhook-delivery] enqueue failed:", err);
139 return null;
140 }
141}
142
143// ---------------------------------------------------------------------------
144// One attempt
145// ---------------------------------------------------------------------------
146
147/**
148 * Run a single delivery attempt against the given row. Looks up the hook URL
149 * fresh (so a deleted hook short-circuits), POSTs, then updates the row to
150 * succeeded / pending (with new next_attempt_at) / dead based on the result.
151 *
152 * Returns 'succeeded' | 'retry' | 'dead' | 'gone' (hook was deleted).
153 */
154export async function attemptDelivery(
155 deliveryId: string
156): Promise<"succeeded" | "retry" | "dead" | "gone"> {
157 // Pull the delivery row + the hook URL in one shot.
158 const rows = await db
159 .select({
160 delivery: webhookDeliveries,
161 url: webhooks.url,
162 isActive: webhooks.isActive,
163 })
164 .from(webhookDeliveries)
165 .leftJoin(webhooks, eq(webhooks.id, webhookDeliveries.webhookId))
166 .where(eq(webhookDeliveries.id, deliveryId))
167 .limit(1);
168
169 const row = rows[0];
170 if (!row) return "gone";
171 if (!row.url || row.isActive === false) {
172 // Hook deleted or disabled between enqueue and attempt — mark dead so
173 // we don't keep polling it.
174 await db
175 .update(webhookDeliveries)
176 .set({
177 status: "dead",
178 lastError: "hook deleted or disabled",
179 lastAttemptedAt: new Date(),
180 })
181 .where(eq(webhookDeliveries.id, deliveryId));
182 return "gone";
183 }
184
185 const d = row.delivery;
186 const attemptNumber = d.attemptCount + 1;
187
188 // Perform the POST.
189 let statusCode: number | null = null;
190 let errorMessage: string | null = null;
191 let success = false;
192
193 try {
194 const headers: Record<string, string> = {
195 "Content-Type": "application/json",
196 "X-Gluecron-Event": d.event,
197 "X-Gluecron-Delivery": d.id,
198 };
199 if (d.signature) headers["X-Gluecron-Signature"] = d.signature;
200
201 const res = await fetch(row.url, {
202 method: "POST",
203 headers,
204 body: d.payload,
205 signal: AbortSignal.timeout(DELIVERY_TIMEOUT_MS),
206 });
207 statusCode = res.status;
208 success = res.status >= 200 && res.status < 300;
209 if (!success) {
210 errorMessage = `HTTP ${res.status}`;
211 }
212 } catch (err) {
213 errorMessage =
214 err instanceof Error ? err.message : String(err ?? "unknown error");
215 if (errorMessage.length > ERROR_CAP) {
216 errorMessage = errorMessage.slice(0, ERROR_CAP);
217 }
218 }
219
220 const now = new Date();
221
222 if (success) {
223 await db
224 .update(webhookDeliveries)
225 .set({
226 status: "succeeded",
227 attemptCount: attemptNumber,
228 lastAttemptedAt: now,
229 lastStatusCode: statusCode,
230 lastError: null,
231 succeededAt: now,
232 nextAttemptAt: null,
233 })
234 .where(eq(webhookDeliveries.id, deliveryId));
235
236 // Best-effort sync of the parent hook row for the legacy "last status"
237 // surface in /settings/webhooks. Never throws out.
238 try {
239 await db
240 .update(webhooks)
241 .set({ lastDeliveredAt: now, lastStatus: statusCode ?? 0 })
242 .where(eq(webhooks.id, d.webhookId));
243 } catch {
244 /* swallow */
245 }
246
247 return "succeeded";
248 }
249
250 // Failure path.
251 if (attemptNumber >= MAX_ATTEMPTS) {
252 await db
253 .update(webhookDeliveries)
254 .set({
255 status: "dead",
256 attemptCount: attemptNumber,
257 lastAttemptedAt: now,
258 lastStatusCode: statusCode,
259 lastError: errorMessage,
260 nextAttemptAt: null,
261 })
262 .where(eq(webhookDeliveries.id, deliveryId));
263
264 try {
265 await db
266 .update(webhooks)
267 .set({ lastDeliveredAt: now, lastStatus: statusCode ?? 0 })
268 .where(eq(webhooks.id, d.webhookId));
269 } catch {
270 /* swallow */
271 }
272
273 return "dead";
274 }
275
276 // Schedule the next attempt.
277 const backoff = BACKOFF_MS[attemptNumber] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
278 const nextAt = new Date(now.getTime() + backoff);
279
280 await db
281 .update(webhookDeliveries)
282 .set({
283 status: "pending",
284 attemptCount: attemptNumber,
285 lastAttemptedAt: now,
286 lastStatusCode: statusCode,
287 lastError: errorMessage,
288 nextAttemptAt: nextAt,
289 })
290 .where(eq(webhookDeliveries.id, deliveryId));
291
292 try {
293 await db
294 .update(webhooks)
295 .set({ lastDeliveredAt: now, lastStatus: statusCode ?? 0 })
296 .where(eq(webhooks.id, d.webhookId));
297 } catch {
298 /* swallow */
299 }
300
301 return "retry";
302}
303
304// ---------------------------------------------------------------------------
305// Drain — claim up to BATCH_SIZE due rows and attempt them.
306// ---------------------------------------------------------------------------
307
308/** Claim and attempt up to BATCH_SIZE due rows. Returns count attempted. */
309export async function drainPendingDeliveries(): Promise<number> {
310 const now = new Date();
311 let due: { id: string }[] = [];
312 try {
313 due = await db
314 .select({ id: webhookDeliveries.id })
315 .from(webhookDeliveries)
316 .where(
317 and(
318 eq(webhookDeliveries.status, "pending"),
319 lte(webhookDeliveries.nextAttemptAt, now)
320 )
321 )
322 .orderBy(asc(webhookDeliveries.nextAttemptAt))
323 .limit(BATCH_SIZE);
324 } catch (err) {
325 console.error("[webhook-delivery] poll failed:", err);
326 return 0;
327 }
328
329 if (due.length === 0) return 0;
330
331 // Run attempts in parallel — they're IO-bound and target different URLs.
332 await Promise.all(
333 due.map((row) =>
334 attemptDelivery(row.id).catch((err) => {
335 console.error(
336 `[webhook-delivery] attempt for ${row.id} threw:`,
337 err
338 );
339 })
340 )
341 );
342
343 return due.length;
344}
345
346// ---------------------------------------------------------------------------
347// Worker
348// ---------------------------------------------------------------------------
349
350let workerStarted = false;
351
352/**
353 * Background poll loop. Idempotent — calling twice is a no-op. Returns a
354 * stop function (used in tests; production never stops).
355 */
356export function startWebhookDeliveryWorker(opts?: {
357 intervalMs?: number;
358}): () => void {
359 if (workerStarted) return () => {};
360 workerStarted = true;
361
362 const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
363 let stopped = false;
364 let active = false;
365
366 const tick = async () => {
367 if (stopped || active) return;
368 active = true;
369 try {
370 // Keep draining while there's work — many due rows can pile up
371 // after an outage of the downstream service.
372 let n = await drainPendingDeliveries();
373 while (n >= BATCH_SIZE && !stopped) {
374 n = await drainPendingDeliveries();
375 }
376 } catch (err) {
377 console.error("[webhook-delivery] worker tick:", err);
378 } finally {
379 active = false;
380 }
381 };
382
383 const handle = setInterval(() => {
384 void tick();
385 }, intervalMs);
386
387 // Best-effort: don't keep the process alive in tests/CLIs.
388 if (typeof (handle as { unref?: () => void }).unref === "function") {
389 (handle as { unref?: () => void }).unref?.();
390 }
391
392 return () => {
393 stopped = true;
394 workerStarted = false;
395 clearInterval(handle);
396 };
397}
398
399// Silence unused-import warnings for `sql` (kept in case future schedule
400// migrations want raw expressions — drizzle's lte/eq cover all current uses).
401void sql;