Skip to content

Commit 407a269

Browse files
feat(webhook): add dead letter queue for failed message processing (#148)
Permanently failed deliveries (max retries exhausted) and SSRF-blocked jobs are now routed to a bounded dead letter queue instead of being silently dropped, preventing silent data loss during downstream outages. Adds inspection, single-entry redelivery, and removal endpoints under /deadletter, Prometheus DLQ metrics, and architecture/runbook documentation. Closes #124
1 parent 2c75bf7 commit 407a269

7 files changed

Lines changed: 649 additions & 32 deletions

File tree

docs/WEBHOOK_ARCHITECTURE.md

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,17 @@ Transient network drops, rate limits (HTTP 429), and short-term receiver outages
9292
- **Full Jitter**: Prevents "thundering herd" issues by introducing randomized delay ($t_{jitter} = \text{random}(0, t_{backoff})$).
9393
- **Max Retries**: Defaulted to **5 attempts** before a webhook is classified as failed.
9494

95-
### 3.6 Distributed Job Scheduler with Lease-based Worker Claiming
95+
### 3.6 Dead Letter Queue (`/deadletter`)
9696

97-
Queue processing runs on a **distributed job scheduler** (`jobScheduler.ts`) where multiple worker loops compete to claim due webhook jobs under short-lived **leases**:
97+
Rather than silently dropping a message that can never be delivered, the service routes permanently failed jobs into a bounded **Dead Letter Queue (DLQ)** for inspection and operator-driven redelivery:
9898

99-
- **Claim protocol**: A job becomes claimable once its `runAt` time has elapsed. A worker acquires the job by claiming a lease from a shared lease registry (`LeaseStore`); while that lease is valid, no other worker can claim the same job, so concurrent replicas/workers can never double-deliver the same webhook.
100-
- **Fencing across processes**: `LeaseStore.claim` is atomic (synchronous within the Node event loop and serialisable against a shared store such as Redis/etcd in production), which gives cross-process mutual exclusion. Each lease carries a monotonic fencing token.
101-
- **Heartbeat / lease renewal**: while a job is executing, the owning worker renews its lease on a configurable interval, so a healthy long-running delivery is never stolen by a competing worker.
102-
- **Crash recovery**: if a worker dies without renewing, its lease expires and another worker reclaims the job — exactly-once under normal operation, at-least-once on worker failure.
103-
- **Retry via rescheduling**: a failed attempt with retries remaining calls `ctx.reschedule(nextAttemptTime)` (exponential backoff + jitter), returning the job to the claimable pool at a future time.
104-
- **Horizontal scaling**: worker count is controlled by `WEBHOOK_WORKER_COUNT` (default `3`). Scaling replicas or raising the worker count increases delivery concurrency without risking duplicate deliveries.
99+
- **What is dead-lettered**: Deliveries that exhaust their maximum retry budget (`MAX_ATTEMPTS_EXHAUSTED`) and jobs rejected by the SSRF shield (`SSRF_BLOCKED`).
100+
- **Retention**: A bounded, in-memory store (default **1,000 entries**, FIFO). When full, the oldest dead letter is evicted and counted as discarded for alerting.
101+
- **Inspection**: `GET /deadletter` lists entries (newest first); `GET /deadletter/:id` fetches a single entry including the failed payload, attempt history, reason, and last error.
102+
- **Redelivery**: `POST /deadletter/:id/requeue` reconstructs a fresh delivery job from the stored entry (with a fresh retry budget) and pushes it back onto the active queue. Requeued deliveries are re-signed and pass through the full security + retry pipeline again.
103+
- **Removal**: `DELETE /deadletter/:id` removes a single entry; `DELETE /deadletter?confirm=true` purges the whole queue. Purging requires an explicit confirmation query parameter to prevent accidental data loss.
104+
105+
The DLQ is intentionally in-memory to mirror the service's ingestion pipeline and keep inspection/redelivery on the fast path. For deployments requiring cross-restart durability, the out-of-process persistent queue pattern (Redis/RabbitMQ) noted in the runbook should be substituted.
105106

106107
---
107108

@@ -112,9 +113,7 @@ The service registers Prometheus counters and histograms to measure health indic
112113
- `webhook_delivery_duration_seconds`: Histogram of endpoint response latency.
113114
- `webhook_queue_size_current`: Gauge representing current queue occupancy.
114115
- `webhook_failures_total`: Total dropped or exhausted delivery alerts.
115-
- `webhook_scheduler_workers_current`: Gauge of active worker loops.
116-
- `webhook_scheduler_active_leases_current`: Gauge of jobs currently executing under a worker lease.
117-
- `webhook_scheduler_jobs_submitted_total`: Counter of jobs submitted to the scheduler.
118-
- `webhook_scheduler_jobs_processed_total`: Counter of jobs executed by workers.
119-
- `webhook_scheduler_jobs_failed_total`: Counter of jobs whose execution threw.
120-
- `webhook_scheduler_lease_reclaimed_total`: Counter of expired leases reclaimed by another worker (crash recovery events).
116+
- `webhook_dead_letter_queue_size_current`: Gauge of the current DLQ occupancy.
117+
- `webhook_dead_letter_enqueued_total`: Counter of messages entering the DLQ, labeled by `reason` (`MAX_ATTEMPTS_EXHAUSTED` | `SSRF_BLOCKED`).
118+
- `webhook_dead_letter_requeued_total`: Counter of dead letters pushed back onto the active queue.
119+
- `webhook_dead_letter_discarded_total`: Counter of dead letters evicted, purged, or manually removed.

docs/WEBHOOK_RUNBOOK.md

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -82,20 +82,39 @@ To ensure the safety of the off-chain system, the SSRF (Server-Side Request Forg
8282

8383
---
8484

85-
## 5. Scheduler & Worker Operations
85+
## 5. Dead Letter Queue (DLQ) Operations
8686

87-
Queue processing runs on the distributed job scheduler, where workers claim jobs under short-lived leases. Diagnose scheduler health through the `webhook_scheduler_*` metrics on `/metrics` and the scheduler block on `/health`.
87+
When a webhook permanently fails after exhausting its retry budget, or is rejected by the SSRF shield, it is moved to the **dead letter queue** instead of being dropped. `webhook_dead_letter_queue_size_current` climbing or `webhook_dead_letter_enqueued_total` increasing indicates persistent downstream failures.
8888

89-
### Health Indicators
90-
- `webhook_scheduler_workers_current` **0** → worker loops are not running; the scheduler cannot drain the queue. Restart the service.
91-
- `webhook_scheduler_active_leases_current` sustained at the worker count → all workers are blocked on slow deliveries; inspect downstream endpoint latency and consider scaling out.
92-
- `webhook_scheduler_lease_reclaimed_total` climbing → workers are expiring mid-delivery (lease not renewed). Investigate event-loop blocking / GC pauses, or increase the lease duration.
89+
### Step 1: Inspect the dead letter queue
90+
```bash
91+
curl -s http://webhook-service.internal/deadletter
92+
# {"count": 3, "deadLetters": [ { "id": "...", "reason": "MAX_ATTEMPTS_EXHAUSTED", ... } ]}
93+
94+
# Inspect a single entry to see the failure reason and last error
95+
curl -s http://webhook-service.internal/deadletter/<job-id>
96+
```
9397

94-
### Diagnosing duplicate or missed deliveries
95-
1. Confirm workers are healthy: `curl -s http://webhook-service.internal/health` and check `scheduler.workers` is non-empty and `scheduler.pendingCount` is not climbing.
96-
2. Confirm no lease thrash: `curl -s http://webhook-service.internal/metrics | grep webhook_scheduler_lease_reclaimed_total`.
97-
3. If `pendingCount` climbs while `active_leases` stays low, a worker crash loop is likely; scale the deployment and inspect container restart counts.
98+
### Step 2: Confirm the root cause before redelivering
99+
1. Verify the downstream endpoint is healthy (`curl` / `GET /health` on the receiver).
100+
2. Confirm the stored `errorMessage` is a transient failure (5xx, timeout) and not a request you should not re-send (e.g. 4xx contract violations).
101+
102+
### Step 3: Redeliver the message
103+
```bash
104+
# Push a single dead letter back onto the active queue with a fresh retry budget
105+
curl -X POST http://webhook-service.internal/deadletter/<job-id>/requeue
106+
# { "status": "REQUEUED", "jobId": "<new-job-id>" }
107+
```
108+
The requeued message is re-signed and passes through the full security + retry pipeline again.
109+
110+
### Step 4: Discard dead letters
111+
```bash
112+
# Remove a single entry
113+
curl -X DELETE http://webhook-service.internal/deadletter/<job-id>
114+
# Purge the entire queue (requires explicit confirmation)
115+
curl -X DELETE http://webhook-service.internal/deadletter?confirm=true
116+
```
98117

99-
### Tuning
100-
- Delivery concurrency per instance: `WEBHOOK_WORKER_COUNT` (default `3`).
101-
- Lease duration and heartbeat are configurable in `jobScheduler.ts` (`leaseDurationMs`, `leaseRenewIntervalMs`, `pollIntervalMs`).
118+
### Operational Notes
119+
- **Bounded retention**: The DLQ holds up to 1,000 entries in memory; the oldest entry is evicted (and counted as `webhook_dead_letter_discarded_total`) when capacity is exceeded.
120+
- **Durability**: The DLQ is in-memory. For transactions that must survive service restarts, deploy the out-of-process persistent queue pattern (Redis/RabbitMQ) as the backing store.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
/**
2+
* Dead Letter Queue (DLQ) for the Webhook Delivery Service.
3+
*
4+
* Webhook deliveries that permanently fail (max attempts exhausted) or that are
5+
* rejected by the SSRF security shield are moved into the dead letter queue
6+
* instead of being silently dropped. This preserves the failed messages for
7+
* inspection, offline retry ("requeue"), and operational forensics, so that a
8+
* downstream subscriber outage or a mis-configured endpoint is never the cause
9+
* of silent data loss.
10+
*
11+
* The queue is backed by an in-memory bounded store (mirroring the rest of the
12+
* service's in-memory delivery pipeline) and is drained through the `/deadletter`
13+
* HTTP endpoints. A pluggable requeue handler is registered by the delivery
14+
* module so that dead letters can be pushed back onto the active delivery queue
15+
* with a fresh retry budget.
16+
*/
17+
import type { WebhookPayload } from './delivery';
18+
import {
19+
trackDeadLetterCount,
20+
trackDeadLetterEnqueued,
21+
trackDeadLetterDiscarded,
22+
} from './metrics';
23+
24+
export type DeadLetterReason = 'MAX_ATTEMPTS_EXHAUSTED' | 'SSRF_BLOCKED';
25+
26+
export interface DeadLetterEntry {
27+
/** Identifier of the original webhook job. */
28+
id: string;
29+
/** Destination endpoint that the webhook was targeting. */
30+
url: string;
31+
/** Event payload that could not be delivered. */
32+
payload: WebhookPayload;
33+
/** Shared secret used for HMAC signing (required for requeue). */
34+
secret: string;
35+
/** Optional Ed25519 private key used for signing (required for requeue). */
36+
privateKey?: string;
37+
/** Number of delivery attempts made before dead-lettering. */
38+
attempts: number;
39+
/** Maximum number of attempts permitted for redelivery. */
40+
maxAttempts: number;
41+
/** Why the message was dead-lettered. */
42+
reason: DeadLetterReason;
43+
/** Human readable error description captured at failure time. */
44+
errorMessage: string;
45+
/** HTTP status code observed on the last failed attempt, if any. */
46+
statusCode?: number;
47+
/** Timestamp (ms) at which the message entered the dead letter queue. */
48+
deadLetteredAt: number;
49+
}
50+
51+
const DEFAULT_MAX_DEAD_LETTERS = 1000;
52+
53+
const store = new Map<string, DeadLetterEntry>();
54+
let maxDeadLetters = DEFAULT_MAX_DEAD_LETTERS;
55+
56+
/*
57+
* The requeue orchestration (dead letter -> active delivery queue) lives in the
58+
* delivery module, which owns both the queue and this store. This module only
59+
* exposes the primitive data operations; requeueing pops an entry and the
60+
* delivery module reconstructs a fresh job from it.
61+
*/
62+
63+
/**
64+
* Insert a permanently failed delivery into the dead letter queue. If the
65+
* queue is at capacity the oldest entry is evicted (FIFO) so the queue stays
66+
* bounded; evicted entries are tracked as discarded for alerting purposes.
67+
*/
68+
export function reportDeadLetter(entry: DeadLetterEntry): void {
69+
if (!store.has(entry.id) && store.size >= maxDeadLetters) {
70+
const oldest = oldestEntryId();
71+
if (oldest) {
72+
store.delete(oldest);
73+
trackDeadLetterDiscarded();
74+
}
75+
}
76+
store.set(entry.id, entry);
77+
trackDeadLetterEnqueued(entry.reason);
78+
trackDeadLetterCount(store.size);
79+
}
80+
81+
/** Return a snapshot of all dead letters, newest first. */
82+
export function getDeadLetters(): DeadLetterEntry[] {
83+
return [...store.values()].sort((a, b) => b.deadLetteredAt - a.deadLetteredAt);
84+
}
85+
86+
/** Return a single dead letter by id. */
87+
export function getDeadLetter(id: string): DeadLetterEntry | undefined {
88+
return store.get(id);
89+
}
90+
91+
/** Return the current number of dead letters. */
92+
export function getDeadLetterCount(): number {
93+
return store.size;
94+
}
95+
96+
/**
97+
* Atomically remove a dead letter from the queue and return it so the caller
98+
* can re-enqueue it as a fresh delivery job. Returns `undefined` if the entry
99+
* does not exist.
100+
*/
101+
export function popDeadLetter(id: string): DeadLetterEntry | undefined {
102+
const entry = store.get(id);
103+
if (!entry) {
104+
return undefined;
105+
}
106+
store.delete(id);
107+
trackDeadLetterCount(store.size);
108+
return entry;
109+
}
110+
111+
/**
112+
* Remove a single dead letter from the queue without requeueing it.
113+
* Returns `true` if an entry was present and removed.
114+
*/
115+
export function removeDeadLetter(id: string): boolean {
116+
const existed = store.delete(id);
117+
if (existed) {
118+
trackDeadLetterCount(store.size);
119+
trackDeadLetterDiscarded();
120+
}
121+
return existed;
122+
}
123+
124+
/** Remove all dead letters from the queue. Returns the number removed. */
125+
export function purgeDeadLetters(): number {
126+
const count = store.size;
127+
if (count > 0) {
128+
store.clear();
129+
trackDeadLetterCount(0);
130+
for (let i = 0; i < count; i++) {
131+
trackDeadLetterDiscarded();
132+
}
133+
}
134+
return count;
135+
}
136+
137+
/** Clear the DLQ and reset internal state (primarily for tests). */
138+
export function resetDeadLetterQueue(): void {
139+
store.clear();
140+
maxDeadLetters = DEFAULT_MAX_DEAD_LETTERS;
141+
trackDeadLetterCount(0);
142+
}
143+
144+
/** Configure the maximum size of the dead letter queue (primarily for tests). */
145+
export function setMaxDeadLetters(size: number): void {
146+
maxDeadLetters = size;
147+
}
148+
149+
/** Return the id of the oldest entry (the FIFO eviction candidate). */
150+
function oldestEntryId(): string | undefined {
151+
let oldestId: string | undefined;
152+
let oldestTime = Infinity;
153+
for (const entry of store.values()) {
154+
if (entry.deadLetteredAt < oldestTime) {
155+
oldestTime = entry.deadLetteredAt;
156+
oldestId = entry.id;
157+
}
158+
}
159+
return oldestId;
160+
}

webhook-delivery-service/src/delivery.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import axios from 'axios';
22
import { generateSignatures, validateUrlForSsrf } from './security';
3-
import { trackDeliveryAttempt, trackQueueSize, trackFailure } from './metrics';
4-
import { JobScheduler, ExecuteContext } from './jobScheduler';
3+
import { trackDeliveryAttempt, trackQueueSize, trackFailure, trackDeadLetterRequeued } from './metrics';
4+
import { reportDeadLetter, popDeadLetter, resetDeadLetterQueue, DeadLetterEntry } from './deadLetterQueue';
55
import { logger, LogAttributes } from './logger';
66

77
// Structured logging is skipped in tests: Jest's console interception adds
@@ -121,12 +121,28 @@ export function getSchedulerStatus() {
121121
return scheduler.getStatus();
122122
}
123123

124+
/**
125+
* Push a dead letter back onto the active delivery queue as a fresh job with a
126+
* fresh retry budget. Returns the new webhook job id, or `null` if the dead
127+
* letter does not exist.
128+
*/
129+
export function requeueDeadLetter(id: string): string | null {
130+
const entry = popDeadLetter(id);
131+
if (!entry) {
132+
return null;
133+
}
134+
const newJobId = enqueueWebhook(entry.payload, entry.url, entry.secret, entry.privateKey, entry.maxAttempts);
135+
trackDeadLetterRequeued();
136+
return newJobId;
137+
}
138+
124139
/**
125140
* Clear queue and logs (primarily for testing)
126141
*/
127142
export function clearQueueAndLogs(): void {
128143
scheduler.clear();
129144
deliveryLogs.length = 0;
145+
resetDeadLetterQueue();
130146
trackQueueSize(0);
131147
}
132148

@@ -170,6 +186,18 @@ async function deliverWebhook(job: WebhookJob, ctx: ExecuteContext) {
170186
if (!ssrfCheck.valid) {
171187
const errorMsg = `SSRF Prevention: ${ssrfCheck.reason}`;
172188
trackFailure();
189+
reportDeadLetter({
190+
id: job.id,
191+
url: job.url,
192+
payload: job.payload,
193+
secret: job.secret,
194+
privateKey: job.privateKey,
195+
attempts: job.attempts,
196+
maxAttempts: job.maxAttempts,
197+
reason: 'SSRF_BLOCKED',
198+
errorMessage: errorMsg,
199+
deadLetteredAt: Date.now(),
200+
});
173201
logDelivery('warn', 'webhook delivery dropped by SSRF check', {
174202
'webhook.id': job.id,
175203
'webhook.event': job.payload.event,
@@ -266,8 +294,21 @@ async function deliverWebhook(job: WebhookJob, ctx: ExecuteContext) {
266294
lastAttemptTime: Date.now(),
267295
});
268296
} else {
269-
// Max attempts exhausted
297+
// Max attempts exhausted -> move to the dead letter queue
270298
trackFailure();
299+
reportDeadLetter({
300+
id: job.id,
301+
url: job.url,
302+
payload: job.payload,
303+
secret: job.secret,
304+
privateKey: job.privateKey,
305+
attempts: job.attempts,
306+
maxAttempts: job.maxAttempts,
307+
reason: 'MAX_ATTEMPTS_EXHAUSTED',
308+
errorMessage: `Max attempts (${job.maxAttempts}) exhausted. Last error: ${errorMessage}`,
309+
statusCode,
310+
deadLetteredAt: Date.now(),
311+
});
271312
logDelivery('error', 'webhook delivery failed permanently', {
272313
'webhook.id': job.id,
273314
'webhook.event': job.payload.event,

0 commit comments

Comments
 (0)