From c5c5a6b0c87ad7a3797e7f8ab695c5f64abfacf8 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Tue, 1 Sep 2026 16:29:03 +0200 Subject: [PATCH] fix(webhooks): write delivery timestamps as ISO strings Applies the repo's documented Jest+SQLite guidance (CLAUDE.md) to the webhook delivery path, which was the last one still passing raw Date objects into knex writes. Under jest those store as the literal string "[object Object]", so next_retry_at came back NaN and the retry/backoff test could not assert on it. Production (PG, and SQLite outside jest) was unaffected. Convert the timestamp writes -- and the `next_retry_at <=` due comparison, which has to stay type-consistent with them -- to .toISOString(), matching the existing precedent in downloadJobService.js. Refs testplan REPORT.md #22 (Part 1.2.01). --- .../integration/webhookDelivery.test.js | 12 ++++---- backend/src/services/webhookDeliveryWorker.js | 28 +++++++++---------- backend/src/services/webhookService.js | 8 +++--- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/__tests__/integration/webhookDelivery.test.js b/backend/__tests__/integration/webhookDelivery.test.js index 14ed1ab5..35de5a18 100644 --- a/backend/__tests__/integration/webhookDelivery.test.js +++ b/backend/__tests__/integration/webhookDelivery.test.js @@ -146,8 +146,8 @@ describe('webhook delivery worker (#327)', () => { payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), attempt_count: 4, status: 'pending', - next_retry_at: new Date(), - created_at: new Date(), + next_retry_at: new Date().toISOString(), + created_at: new Date().toISOString(), }); await __test.tick(); @@ -190,8 +190,8 @@ describe('webhook delivery worker (#327)', () => { payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), attempt_count: 0, status: 'pending', - next_retry_at: new Date(), - created_at: new Date(), + next_retry_at: new Date().toISOString(), + created_at: new Date().toISOString(), }); await __test.tick(); @@ -214,8 +214,8 @@ describe('webhook delivery worker (#327)', () => { payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }), attempt_count: 0, status: 'pending', - next_retry_at: new Date(), - created_at: new Date(), + next_retry_at: new Date().toISOString(), + created_at: new Date().toISOString(), }); await __test.tick(); diff --git a/backend/src/services/webhookDeliveryWorker.js b/backend/src/services/webhookDeliveryWorker.js index a619e05b..2bb91cb4 100644 --- a/backend/src/services/webhookDeliveryWorker.js +++ b/backend/src/services/webhookDeliveryWorker.js @@ -51,7 +51,7 @@ async function fetchPending(limit) { const excludeIds = Array.from(inFlight); let q = db('webhook_deliveries') .where('status', 'pending') - .where('next_retry_at', '<=', new Date()) + .where('next_retry_at', '<=', new Date().toISOString()) .orderBy('next_retry_at', 'asc') .limit(limit); if (excludeIds.length > 0) { @@ -71,7 +71,7 @@ async function deliverOne(row) { .update({ status: 'failed', last_error: 'webhook subscription no longer exists', - completed_at: new Date(), + completed_at: new Date().toISOString(), attempt_count: row.attempt_count + 1, }); return; @@ -85,7 +85,7 @@ async function deliverOne(row) { .update({ status: 'failed', last_error: 'webhook is disabled', - completed_at: new Date(), + completed_at: new Date().toISOString(), attempt_count: row.attempt_count + 1, }); return; @@ -172,10 +172,10 @@ async function deliverOne(row) { response_body: truncate(stringifyBody(response.data), RESPONSE_TRUNCATE_BYTES), latency_ms: latency, attempt_count: newAttempt, - completed_at: new Date(), + completed_at: new Date().toISOString(), next_retry_at: null, }); - await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date() }); + await db('webhooks').where({ id: webhook.id }).update({ last_success_at: new Date().toISOString() }); return; } @@ -194,10 +194,10 @@ async function deliverOne(row) { last_error: errorMsg, latency_ms: latency, attempt_count: newAttempt, - completed_at: new Date(), + completed_at: new Date().toISOString(), next_retry_at: null, }); - await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() }); + await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() }); return; } @@ -211,9 +211,9 @@ async function deliverOne(row) { last_error: errorMsg, latency_ms: latency, attempt_count: newAttempt, - next_retry_at: new Date(Date.now() + backoff), + next_retry_at: new Date(Date.now() + backoff).toISOString(), }); - await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() }); + await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() }); } async function markFailedFinal(row, reason) { @@ -223,10 +223,10 @@ async function markFailedFinal(row, reason) { status: 'failed', last_error: reason, attempt_count: row.attempt_count + 1, - completed_at: new Date(), + completed_at: new Date().toISOString(), next_retry_at: null, }); - await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date() }); + await db('webhooks').where({ id: row.webhook_id }).update({ last_failure_at: new Date().toISOString() }); } // Schedule the normal retry/backoff for a transient failure that must not @@ -242,7 +242,7 @@ async function scheduleTransientRetry(row, webhook, errorMsg) { status: 'failed', last_error: errorMsg, attempt_count: newAttempt, - completed_at: new Date(), + completed_at: new Date().toISOString(), next_retry_at: null, }); } else { @@ -253,10 +253,10 @@ async function scheduleTransientRetry(row, webhook, errorMsg) { status: 'pending', last_error: errorMsg, attempt_count: newAttempt, - next_retry_at: new Date(Date.now() + backoff), + next_retry_at: new Date(Date.now() + backoff).toISOString(), }); } - await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date() }); + await db('webhooks').where({ id: webhook.id }).update({ last_failure_at: new Date().toISOString() }); } function stringifyBody(data) { diff --git a/backend/src/services/webhookService.js b/backend/src/services/webhookService.js index 9f450d66..80e58c9d 100644 --- a/backend/src/services/webhookService.js +++ b/backend/src/services/webhookService.js @@ -190,8 +190,8 @@ async function fire(eventType, data) { payload: JSON.stringify(envelope), attempt_count: 0, status: 'pending', - next_retry_at: now, - created_at: now, + next_retry_at: now.toISOString(), + created_at: now.toISOString(), }); } @@ -232,8 +232,8 @@ async function enqueueForWebhook(webhookId, eventType, data) { payload: JSON.stringify(envelope), attempt_count: 0, status: 'pending', - next_retry_at: now, - created_at: now, + next_retry_at: now.toISOString(), + created_at: now.toISOString(), }); return { enqueued: true, webhookId: w.id, deliveryId: deliveryUuid }; } catch (err) {