Repository navigation
Harden schedule/diff-alert webhook delivery: retry, backoff, DLQ, and delivery API #26
Description
Context
Scheduled re-scrapes fire diff alerts + HMAC-signed webhooks (runSchedule/createAlertForSchedule/deliverWebhook, apps/api/src/worker.ts:3910-4024) on the */30 cron (wrangler.jsonc triggers). Delivery is fire-once: attempts is hardcoded to 1 on both success and failure (worker.ts:4010/4018), there is no retry/backoff, no dead-letter, and no way to list or redeliver a failed webhook. processDueSchedules also silently swallows per-schedule errors (.catch(() => undefined), worker.ts:3906) and only handles LIMIT 10 per tick.
Goal / user story
As a user relying on change alerts, I want webhook deliveries to retry with backoff and be inspectable/redeliverable, so a transient 500 on my endpoint doesn't permanently drop a "context changed" notification.
Acceptance criteria
- Migration adds
next_attempt_at,max_attempts(default 5), and a payload-snapshot column tocontextmem_webhook_deliveries; the status CHECK is extended with'dead'. -
deliverWebhookincrementsattempts(not hardcoded1) and, on failure (non-2xx or throw), schedules a retry with exponential backoff (e.g. 1m/5m/30m/2h/12h) untilmax_attempts, then marks'dead'. - A sweep in the existing
*/30cron (or a dedicated Queue) re-attempts rows wherestatus IN ('queued','failed')andnext_attempt_at <= now. -
GET /api/webhook-deliveries?scheduleId=(owner-scoped) lists deliveries with status/attempts/last error;POST /api/webhook-deliveries/:id/redeliverre-queues one. - Per-schedule failures in
processDueSchedulesare recorded (thecontextmem_schedule_runsstatus='failed' write atworker.ts:3955is preserved) rather than silently dropped at the loop.catch(worker.ts:3906). - Tests: a 500 endpoint is retried then dead-lettered; a recovered endpoint succeeds on retry; redeliver re-sends with a valid
x-contextmem-signature(signWebhookPayload,worker.ts:4026).
Implementation notes
Reuse the existing contextmem_webhook_deliveries table (migrations/0004_growth_loops.sql) and HMAC signer. Prefer the bound Queue pattern (CONTEXTMEM_EXTRACT_QUEUE) or add a dedicated contextmem-webhooks queue in wrangler.jsonc for retry fan-out; for alpha the */30 cron sweep alone is sufficient. Persist the exact request body so redelivery is byte-identical (keeps the signature valid). Keep the next_run_at scheduling math (worker.ts:3946) intact. Raising the LIMIT 10 / paginating processDueSchedules is a follow-up once schedule count grows.
Sui Overflow angle
Reliable, inspectable change-alerts are the visible, demo-able payoff of the freshness pipeline ("watch a page → ContextMeM re-extracts → your agent/Slack gets a signed webhook"), and a webhook can carry the on-chain attribution receipt id once receipts land — making delivery reliability part of the provenance story, not a throwaway notification.
Dependencies
Owner-scoped delivery endpoints depend on the accounts/owner-auth issue. None otherwise.
Part of the ContextMEM roadmap (#4) • Sui Overflow build.
Activity
harrymove-ctrl commentedon Jun 19, 2026
Implementation Plan
Approach
Make webhook delivery durable by persisting the exact request body + signature on the delivery row, replacing the hardcoded
attempts = 1with a real attempt counter + exponential-backoff scheduler, and re-attempting due rows from a sweep added to the existing*/30cron (no new Queue for alpha). Split the current monolithicdeliverWebhook(worker.ts:3979-4024) intoenqueueWebhookDelivery(build payload + signature, insert row, fire first attempt) andattemptWebhookDelivery(fetch, increment, decide sent/failed/dead) so the cron sweep and the manual redeliver endpoint reuse one code path. Owner-scoped list/redeliver endpoints joincontextmem_webhook_deliveries→contextmem_alerts(deliveries carry noowner_idtoday; the alert does).Prerequisites & dependencies
/api/schedules+/api/alertsendpoints already use the in-place pattern:requireImportAuthorization(sharedCONTEXTMEM_NAMESPACE_IMPORT_TOKENbearer,worker.ts:4436) plus an?ownerId=filter (listAlerts,worker.ts:1741). Reuse that exact pattern — do not block on real accounts.apps/api/cloudflare/wrangler.jsonc:52-54(["*/30 * * * *"]).CONTEXTMEM_WEBHOOK_SECRETalready inWorkerEnv(worker.ts:39) as the HMAC fallback.contextmem/contextmem-staging) viawrangler d1 migrations apply— D1 is not auto-migrated on deploy.contextmem-webhooksQueue deferred.Step-by-step
apps/api/migrations/0007_webhook_retry.sql(next after0006_namespace_build_sources.sql). SQLite cannotALTERaCHECKconstraint, and the existing table has astatus IN ('queued','sent','failed')CHECK (0004_growth_loops.sql:77), so do a table rebuild: createcontextmem_webhook_deliveries_newwith all existing columns +next_attempt_at TEXT,max_attempts INTEGER NOT NULL DEFAULT 5,payload TEXT,signature TEXT,schedule_id TEXT, andstatus TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dead'));INSERT ... SELECTold rows (new cols default to NULL/5);DROP TABLE contextmem_webhook_deliveries;ALTER TABLE ..._new RENAME TO contextmem_webhook_deliveries; recreatecontextmem_webhook_deliveries_alert_idxand addcontextmem_webhook_deliveries_sweep_idx ON contextmem_webhook_deliveries(status, next_attempt_at). (No FK references this table, so the rebuild is safe.) Storeschedule_iddirectly so the sweep/redeliver don't need an alert join for resend.worker.ts:const WEBHOOK_BACKOFF_MS = [60_000, 300_000, 1_800_000, 7_200_000, 43_200_000];(1m/5m/30m/2h/12h) and aWebhookDeliveryRowtype mirroring the new columns (model onScheduleRow,worker.ts:185).deliverWebhook→enqueueWebhookDelivery(worker.ts:3979). Buildbody+signature(unchanged signersignWebhookPayload,worker.ts:4026) as today, butINSERTwithattempts = 0,status = 'queued',next_attempt_at = now,max_attempts = 5, and persistpayload = body,signature,schedule_id,webhook_url. Then callattemptWebhookDelivery(deliveryRow, env)for the immediate first try (preserves current fire-on-create UX). Update the callercreateAlertForSchedule(worker.ts:3976) to call the renamed fn.attemptWebhookDelivery(row, env)(new fn inworker.ts). POSTrow.payloadwith headerx-contextmem-signature: row.signature(verbatim → keeps the HMAC valid). Computeconst attempts = Number(row.attempts) + 1;(replaces hardcoded1atworker.ts:4010/4018). On 2xx:status='sent',status_code,attempts,next_attempt_at=NULL. On non-2xx or throw: ifattempts >= max_attempts→status='dead'; elsestatus='failed',next_attempt_at = new Date(Date.now() + WEBHOOK_BACKOFF_MS[Math.min(attempts-1, WEBHOOK_BACKOFF_MS.length-1)]).toISOString(); always recordstatus_code/error+attempts+updated_at. Never throw out of this fn (so the sweep loop never aborts mid-batch).sweepWebhookDeliveries(env)(newexport async function, sibling ofprocessDueSchedulesatworker.ts:3893).SELECT * ... WHERE status IN ('queued','failed') AND next_attempt_at IS NOT NULL AND next_attempt_at <= ? ORDER BY next_attempt_at ASC LIMIT 50, thenforeach rowawait attemptWebhookDelivery(row, env). Wire it into the cron: in thescheduled()handler (worker.ts:540-544) runprocessDueSchedules(env, ctx)thensweepWebhookDeliveries(env)(both underctx.waitUntil/ awaited)..catch(() => undefined)atworker.ts:3906to.catch((err) => console.error("[schedule] run failed", schedule.id, err)). Thecontextmem_schedule_runsstatus='failed'write insiderunSchedule's catch (worker.ts:3955) already persists and re-throws, so the failure row is preserved and now also logged (satisfies AC5) — keep thenext_run_atmath atworker.ts:3946untouched./api/alertsblock (worker.ts:743-747):GET /api/webhook-deliveries→requireImportAuthorizationgate, thenlistWebhookDeliveries(request, env): read?scheduleId=+?ownerId=, runSELECT d.* FROM contextmem_webhook_deliveries d JOIN contextmem_alerts a ON a.id = d.alert_id WHERE a.owner_id = ? AND (? IS NULL OR a.schedule_id = ?) ORDER BY d.created_at DESC LIMIT 100, map through a newpublicWebhookDelivery(id, status, attempts, maxAttempts, statusCode, lastError, nextAttemptAt, createdAt/updatedAt — model onpublicSchedule,worker.ts:4116).POST /api/webhook-deliveries/:id/redeliver→ gate, load the row + verify owner via the same alert join, resetstatus='queued',next_attempt_at=now, thenawait attemptWebhookDelivery(row, env)and return the refreshedpublicWebhookDelivery. 404 if not found/owner mismatch.wrangler.jsoncchange (cron sweep path). Add a one-line comment noting the dedicatedcontextmem-webhooksQueue is the documented follow-up if sweep volume grows.New / changed files
apps/api/migrations/0007_webhook_retry.sql(new)apps/api/src/worker.ts(changed:scheduledhandler ~540;processDueSchedulescatch 3906; rename/splitdeliverWebhook3979-4024 +createAlertForSchedule3976; newattemptWebhookDelivery,sweepWebhookDeliveries,listWebhookDeliveries, redeliver handler,publicWebhookDelivery,WebhookDeliveryRow,WEBHOOK_BACKOFF_MS; new route entries ~747)apps/api/src/worker.test.ts(changed: extendMemoryD1DatabaseSQL parser +mockFetch; add 3 tests)Test plan (vitest, in
apps/api/src/worker.test.ts; runs in CI viabun-check.yml)MemoryD1Database(the prefix-matched parser atworker.test.ts:1070-1083) to handle the new insert columns (payload/signature/schedule_id/max_attempts/next_attempt_at), theattempts = N/'dead'/next_attempt_atupdate branches, the sweepSELECT ... WHERE status IN ('queued','failed'), and the deliveries↔alerts join. ExtendmockFetch(worker.test.ts:1092) to (a) accept{ body, status }per-route (already supported) and (b) record each call's headers + body so tests can assert the signature.WEBHOOK_BACKOFF_MS[min(attempts-1,4)]produces 1m/5m/30m/2h/12h for attempts 1-5.{ status: 500 }; runprocessDueSchedules; assert deliverystatus='failed',attempts=1,next_attempt_atset. Then loop: setnext_attempt_atto a past time + callsweepWebhookDeliveries4×; assertattemptsclimbs 2→5 and finalstatus='dead'.{status:500}→failed; flip the mock route to200; backdatenext_attempt_at;sweepWebhookDeliveries→status='sent',status_code=200.POST /api/webhook-deliveries/:id/redeliverwith the import bearer; assert the recorded outbound request body is byte-identical to the storedpayloadandx-contextmem-signature===signWebhookPayload(payload, secret)(importsignWebhookPayload/export if needed). Assert owner-mismatch → 404.GET /api/webhook-deliveries?scheduleId=&ownerId=returns the rows withstatus/attempts/lastError; wrongownerIdreturns empty.worker.test.ts:718) still passes —status='sent',status_code=200, butattemptsis now1from the counter (not hardcoded). Update its assertion if it pins internals.*/30ticks (or invokewrangler d1 executeto backdatenext_attempt_at), confirmattemptsincrements and row flips todead; then verify a 200 endpoint succeeds andGET /api/hosted/webhook-deliveries?scheduleId=...(the/api/hosted/*alias normalizes to/api/*,worker.ts:581) lists it.Risks & gotchas
'dead'to the status CHECK in SQLite/D1 is the create-copy-drop-rename rebuild (step 1). Verify column order in theINSERT ... SELECTand recreate both indexes, or the sweep loses its index. Test the migration against a copy of prod data (CI migration-apply check, roadmap §6 CI item).fetches inside one cron invocation. CapLIMIT 50and rely onctx.waitUntil; if delivery volume grows, move to the dedicatedcontextmem-webhooksQueue (issue's stated follow-up) which gives native per-message retry/DLQ.x-contextmem-signature+alertIdis the idempotency key for consumers.next_attempt_atis honored only every 30 min, so a "1m" backoff effectively fires on the next tick — acceptable for alpha; note it in the delivery UI copy.max_attemptsdefault on backfilled rows: old rows getmax_attempts=5andnext_attempt_at=NULL, so the sweep'snext_attempt_at IS NOT NULLguard prevents re-attempting historical fire-once rows. Keep that guard.?ownerId=filter (not per-user identity) until the accounts issue lands — the join enforces ownerId at the data layer, matching today's/api/schedulessecurity posture; flag for hardening when real accounts arrive.Acceptance-criteria mapping
next_attempt_at,max_attempts(default 5), payload snapshot col; CHECK extended with'dead'→ Step 1.deliverWebhookincrementsattempts(not hardcoded 1) + exponential backoff untilmax_attemptsthen'dead'→ Steps 3, 4 (+ constant in Step 2).*/30cron re-attemptsstatus IN ('queued','failed')andnext_attempt_at <= now→ Step 5.GET /api/webhook-deliveries?scheduleId=(owner-scoped) +POST /api/webhook-deliveries/:id/redeliver→ Step 7.failedwrite atworker.ts:3955preserved) not silently dropped at the loop.catch→ Step 6.x-contextmem-signature→ Test plan (500→dead, recovery, redeliver+signature).Effort: M — ~2-3 person-days (1 migration + focused single-file backend change + mock-harness extension and 3 tests; the riskiest piece is the CHECK rebuild and extending the bespoke in-memory D1 mock).
Implementation plan generated for execution (planning phase). Part of the roadmap (#4).