Skip to content

Retries and dead letters: a webhook sender

Deliver webhooks with retries spaced out by retryDelay, an idempotency key from the message, a dead-letter queue for what never succeeds, and a clean stop on SIGTERM.

Updated View as Markdown

When a booking is confirmed, send booking.confirmed to the customer’s URL. Their server fails sometimes, and a crash at the wrong moment can make you call it twice.

One message per call, in a queue with retries (retryLimit: 5 and a retryDelay). The handler throws when the call fails: the message comes back after the delay, and goes to the dead-letter queue after the last try. The message’s transactionId travels as the Idempotency-Key, so the receiver can drop a second call.

The sender pops evt-9001 and calls the customer's URL with Idempotency-Key evt-9001. The receiver answers 500, so the handler throws and the message comes back after retryDelay. The second call succeeds and the receiver stores the event once under its key. If a crash had made the sender call twice, the receiver would drop the second call by its key. After the last of five tries a message goes to the dead-letter queue.The sender pops evt-9001 and calls the customer's URL with Idempotency-Key evt-9001. The receiver answers 500, so the handler throws and the message comes back after retryDelay. The second call succeeds and the receiver stores the event once under its key. If a crash had made the sender call twice, the receiver would drop the second call by its key. After the last of five tries a message goes to the dead-letter queue.
A failed call comes back after the delay; the key makes it count once.
const stop = new AbortController()
process.once('SIGTERM', () => stop.abort())       // a deploy stops the sender cleanly

await queen.queue('webhooks')
  .group('sender')
  .subscriptionMode('all')
  .each()
  .consume(async (msg) => {
    const res = await fetch(msg.data.url, {
      method: 'POST',
      headers: { 'content-type': 'application/json', 'idempotency-key': msg.transactionId },
      body: JSON.stringify(msg.data.event),
    })
    if (!res.ok) throw new Error(`HTTP ${res.status}`)  // retried, then dead-lettered
  }, { signal: stop.signal })

The receiver failed the first call. The message came back after the delay, the second call worked, and the receiver kept the event once, under evt-9001.

Why
the call happens outside any commit, so it can happen twice: a crash after the call and before the ack sends it again. The receiver dropping the second call by its key is what makes it count once. Queen gives the stable key, the retries and the dead-letter queue.

One thing to know: a failed message holds its partition for the delay. Use one partition per customer, so a customer whose server is down waits on its own retries alone.

Built from: Retries and DLQ, Acknowledge.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close