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).
This commit is contained in:
Paul Nothaft
2026-09-01 16:29:03 +02:00
parent 31ffbc8ae4
commit c5c5a6b0c8
3 changed files with 24 additions and 24 deletions
+14 -14
View File
@@ -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) {
+4 -4
View File
@@ -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) {