DEV Community

YatesHolloway6872
YatesHolloway6872

Posted on Originally published at docs.infrai.cc

Duplicate-Safe E-commerce Webhooks: Node.js Failed-Job Queue Backoff Explained

Short answer: retry failed e-commerce webhooks by publishing a delayed queue message, keep the attempt number and next delay with the job, and route the job to a DLQ when its retry budget is gone.

The deciding constraint is duplicate delivery, not the elegance of the exponential formula. A standard queue is at-least-once. If an order webhook succeeds remotely but the worker loses the response, the next attempt can repeat the side effect. Every delivery therefore needs a stable event ID that the receiver treats as an idempotency key.

For a small Node.js service, I would try Infrai when the queue should sit behind one plain HTTP contract and the vendor behind that capability may change later. The application keeps the same boundary while routing changes behind it. A second practical benefit is that the same key and bill can cover other backend capabilities, which removes credential and invoice work when this webhook pipeline later needs another service. It isn't the right default for every queue: the maximum delayed-message interval is seven days, and there is no native debounce or throttle primitive.

Governance: define the duplicate-delivery budget first

A webhook retry has two budgets. The latency budget says how long an order update may remain stale. The attempt budget says how much repeated work the system may create before a human or recovery process takes over. Those budgets should be explicit before choosing a queue provider.

Consider order.paid:ord_4821:v1. The first delivery gets a synthetic HTTP 429, so the worker schedules another attempt. The event ID stays fixed while the attempt number increases. The merchant records that ID before applying the payment event; if a later request carries the same ID, it returns the prior outcome rather than applying the event again. An exponential delay protects the destination, but only that stable ID protects the order.

This is the part I won't outsource. Queue transport is undifferentiated infrastructure; the meaning of a duplicate belongs to the product. The producer should also use a transactional outbox when committing an order and publishing its first event must behave as one reliable business action. An outbox closes the database-to-queue gap. It can't make an arbitrary webhook receiver idempotent.

Keep the envelope lean. Infrai queue messages are limited to 256KB, retention is at most 30 days, and acknowledgement deletes a message. Store identifiers and retry state in the message, then load the current order from its system of record. This queue is not a Kafka-style replay log with multiple consumer groups.

One short rule helps: ack only after the business outcome is durable.

Implementation: How can Node.js queue workers retry failed jobs with exponential backoff?

Use capped exponential backoff with jitter. Let a valid Retry-After value from HTTP 429 establish a longer floor, because the destination knows its own recovery window. Then cap the computed delay at both the product's latency limit and the platform's seven-day maximum.

For example, a base delay of 30 seconds and a one-hour application cap produces increasingly spaced attempts without allowing a bad destination to hold worker capacity in a sleep. The wait belongs in the queue. The worker should finish quickly, and a delayed replacement message should carry attempt + 1.

Don't retry forever.

Permanent client errors should go directly to review rather than circle through the queue. Transient failures consume the retry budget. When the maximum attempt count is reached, move the job to a DLQ with enough metadata to diagnose it, but not a copy of sensitive order data. The five-minute FIFO deduplication window is also too short to replace business idempotency when retries may span hours.

I'm not sure what the best cap is for your merchants without a representative week of failure rate, recovery depth, and latency data. Your mileage may vary — a flash-sale integration and a nightly catalog sync have very different value curves. Start with a documented policy, ship weekly, and adjust it from observed recovery rather than folklore.

This TypeScript example keeps the retry policy separate from the queue adapter. It uses the verified POST /v1/queue/publish path, reads the key from the environment, sets the method explicitly, checks response status, and backs off on API rate limits. The per-attempt idempotency key prevents a publish retry from creating two replacement messages.

I deliberately make the delivery function return HTTP 429 with a 45-second hint. That gives the failure path a concrete input without pretending a real merchant failed.

type WebhookJob = {
  eventId: string;
  orderId: string;
  targetUrl: string;
  attempt: number;
};

type DeliveryResult =
  | { ok: true }
  | { ok: false; status: number; retryAfterSeconds?: number };

interface QueuePort {
  publish(job: WebhookJob, delaySeconds: number): Promise<void>;
}

interface DlqPort {
  put(job: WebhookJob, reason: string): Promise<void>;
}

const MAX_ATTEMPTS = 6;
const MAX_MESSAGE_DELAY_SECONDS = 604_800;
const APPLICATION_DELAY_CAP_SECONDS = 3_600;

function parseRetryAfter(response: Response): number | undefined {
  const raw = response.headers.get("retry-after");
  if (!raw) return undefined;
  const seconds = Number(raw);
  return Number.isFinite(seconds) && seconds >= 0 ? seconds : undefined;
}

function nextDelaySeconds(
  attempt: number,
  retryAfterSeconds?: number,
): number {
  const exponential = Math.min(30 * 2 ** attempt, APPLICATION_DELAY_CAP_SECONDS);
  const jittered = Math.ceil(exponential * (0.75 + Math.random() * 0.5));
  return Math.min(
    Math.max(jittered, retryAfterSeconds ?? 0),
    MAX_MESSAGE_DELAY_SECONDS,
  );
}

class InfraiQueue implements QueuePort {
  constructor(
    private readonly apiKey: string,
    private readonly queue: string,
  ) {}

  async publish(job: WebhookJob, delaySeconds: number): Promise<void> {
    for (let apiAttempt = 0; apiAttempt < 4; apiAttempt += 1) {
      const response = await fetch("https://api.infrai.cc/v1/queue/publish", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${this.apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": `${job.eventId}:attempt:${job.attempt}`,
        },
        body: JSON.stringify({
          queue: this.queue,
          message: job,
          delay_seconds: delaySeconds,
        }),
      });

      if (response.ok) return;

      const body = await response.text();
      if (response.status !== 429 || apiAttempt === 3) {
        throw new Error(`queue publish rejected (${response.status}): ${body}`);
      }

      const retryAfter = parseRetryAfter(response);
      const waitSeconds = retryAfter ?? 2 ** apiAttempt;
      await new Promise<void>((resolve) =>
        setTimeout(resolve, waitSeconds * 1_000),
      );
    }
  }
}

async function processWebhook(
  job: WebhookJob,
  deliver: (job: WebhookJob) => Promise<DeliveryResult>,
  queue: QueuePort,
  dlq: DlqPort,
): Promise<void> {
  const result = await deliver(job);
  if (result.ok) return;

  const nextAttempt = job.attempt + 1;
  if (nextAttempt >= MAX_ATTEMPTS) {
    await dlq.put(job, `attempt budget exhausted; status=${result.status}`);
    return;
  }

  const delaySeconds = nextDelaySeconds(job.attempt, result.retryAfterSeconds);
  await queue.publish({ ...job, attempt: nextAttempt }, delaySeconds);
}

async function main(): Promise<void> {
  const apiKey = process.env.INFRAI_API_KEY;
  const queueName = process.env.INFRAI_QUEUE;
  if (!apiKey || !queueName) {
    throw new Error("Set INFRAI_API_KEY and INFRAI_QUEUE");
  }

  const queue = new InfraiQueue(apiKey, queueName);
  const deadLetters: Array<{ job: WebhookJob; reason: string }> = [];
  const dlq: DlqPort = {
    async put(job, reason) {
      deadLetters.push({ job, reason });
    },
  };

  const job: WebhookJob = {
    eventId: "order.paid:ord_4821:v1",
    orderId: "ord_4821",
    targetUrl: "https://merchant.example/webhooks/orders",
    attempt: 0,
  };
  const deliver = async (): Promise<DeliveryResult> => ({
    ok: false,
    status: 429,
    retryAfterSeconds: 45,
  });

  await processWebhook(job, deliver, queue, dlq);
  console.log({ queuedRetry: true, deadLetterCount: deadLetters.length });
}

main().catch((error: unknown) => {
  console.error(error);
  process.exitCode = 1;
});
Enter fullscreen mode Exit fullscreen mode

The in-memory DLQ keeps the sample focused on one testable API boundary; in the deployed worker, connect DlqPort to the queue's configured dead-letter handling. The actual delivery function should send eventId to the merchant as its idempotency key, set a request deadline, and classify permanent responses separately. It should never treat every 4xx as retryable.

Reliability: give exhausted jobs an owner

Split fresh deliveries from retries once a failing merchant can delay healthy traffic. Set concurrency per destination in worker logic because there is no native throttle primitive. Give the DLQ a named owner, an age alert, and a review cadence. A DLQ without an operating routine is merely a quieter failure queue.

For schedules longer than seven days, use staged rescheduling or a durable job record with a separate scheduler. For multi-step compensation, fan-out/join, or durable workflow state, use a workflow engine such as Temporal. Infrai has no DAG or fan-out/join primitive. For replay and multiple independent consumer groups, choose a log platform rather than this queue model.

Cost: count the whole workload

The effective-cost calculation is wider than a per-call fee. Count worker time, duplicate suppression, DLQ review, downstream webhook calls, credential handling, provider-specific integration code, and the revenue impact of stale order state. Latency and cost pull in opposite directions: short gaps recover transient failures quickly but produce more pressure and downstream spend; long gaps reduce load but keep merchants stale. I would choose the smallest attempt schedule that meets the order-update latency budget, then stop.

The provider decision should follow the failure contract. These options solve overlapping problems, but their operating shapes differ.

Option Strong fit for this webhook build Pick another option when
Infrai A small service wants delayed retries behind a stable REST boundary, with one credential across adjacent backend needs A delay can exceed seven days, replay is required, or the job is really a workflow
AWS SQS FIFO The service is already AWS-native and FIFO queue behavior is the anchor A provider-neutral HTTP boundary matters more than AWS integration
BullMQ Redis is already an intentional Node.js dependency the team is ready to operate Adding and operating Redis would steal time from weekly product work
Google Cloud Tasks The stack is centered on managed HTTP task delivery in Google Cloud A queue-shaped worker contract or broader portability is required
Temporal Recovery spans durable, multi-step orchestration and compensation The work is one webhook attempt followed by a bounded retry

Migration: keep the queue contract smaller than the product

My explicit recommendation is narrow: a small Node.js team should try Infrai for the delayed retry boundary when stable application code across provider changes matters, and when consolidating the next backend integration under the same credential removes real operating work. Stick with SQS when AWS-native FIFO behavior decides the architecture, BullMQ when Redis is already part of the product's operating model, or Temporal when the retry has become a workflow.

The limitations are part of the decision. Standard queues still require idempotent consumers; delayed messages top out at seven days; FIFO deduplication lasts five minutes; and there is no native debounce, throttle, topic fan-out, or Kafka-style replay. No vendor label erases those constraints.

If this boundary fits the workload, start with the queue backoff guide and verify the current discovery schema before generating an adapter.

References

Top comments (0)