Resource library

QA How-To

Test Dead Letter Queue Retries: Behavior and Backoff

Learn to test dead letter queue retries with deterministic TypeScript tests for retry limits, backoff, metadata, poison messages, and successful recovery.

18 min read | 2,667 words

TL;DR

Drive controlled failures and assert attempts, delays, recovery, settlement, and the exact dead-letter envelope. Keep most tests deterministic and add one real-broker contract test.

Key Takeaways

  • Inject time for deterministic retry tests.
  • Assert total attempts and ordered delays.
  • Verify recovery avoids DLQ publication.
  • Test poison messages separately.
  • Assert the complete dead-letter contract.
  • Prevent acknowledgment when DLQ publication fails.

To test dead letter queue retries, prove the attempt count, backoff, recovery path, final dead-letter record, and acknowledgment decision. A test that checks only whether a message reaches a DLQ can miss duplicate publication, lost metadata, or premature acknowledgment.

This hands-on tutorial complements the Event-Driven API Testing Complete Guide. You will build a deterministic TypeScript retry harness, run it with Vitest, and then map the same checks to a real broker.

What You Will Build

  • A transport-neutral retry executor with explicit total attempts.
  • Fast tests for recovery, exhaustion, exponential backoff, and poison messages.
  • An exact contract assertion for the dead-letter envelope.
  • A failure test that prevents data loss when DLQ publication fails.
  • A broker-backed verification checklist for Kafka, RabbitMQ, or a cloud queue.

Prerequisites

Use Node.js 22 LTS or a later supported LTS, npm, TypeScript 5.7 or later, and Vitest 3 or later.

mkdir dlq-retry-tests && cd dlq-retry-tests
npm init -y
npm install --save-dev typescript vitest @types/node
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --strict
mkdir -p src test

Add "test": "vitest run" to the scripts object in package.json.

Verification: Run npm test -- --passWithNoTests. It should exit successfully. If module errors appear later, confirm that both module and moduleResolution are NodeNext.

Step 1: Define the Dead-Letter Contract

Create src/contracts.ts:

export type Message<T> = {
  id: string;
  topic: string;
  payload: T;
  headers?: Record<string, string>;
};

export type DeadLetter<T> = {
  original: Message<T>;
  attempts: number;
  failedAt: string;
  error: { name: string; message: string };
};

export interface DeadLetterPublisher {
  publish<T>(letter: DeadLetter<T>): Promise<void>;
}

Define attempts as total handler calls, including initial delivery. This removes the common ambiguity where three retries might mean four calls. Preserve message ID, source, correlation headers, and safe payload fields because operators need them for diagnosis and replay. Normalize errors instead of serializing runtime Error objects, whose stacks can expose secrets or local paths.

Verification: Run npx tsc --noEmit. Confirm the contract answers four questions without logs: what failed, where it came from, how often it ran, and why it stopped.

Step 2: Implement Deterministic Retries

Create src/retry.ts:

import type { DeadLetterPublisher, Message } from "./contracts.js";

export class NonRetryableError extends Error {
  override name = "NonRetryableError";
}

type Policy = {
  maxAttempts: number;
  initialDelayMs: number;
  multiplier: number;
};

type Dependencies<T> = {
  handler: (message: Message<T>) => Promise<void>;
  deadLetters: DeadLetterPublisher;
  sleep: (ms: number) => Promise<void>;
  now: () => Date;
};

export async function processWithRetry<T>(
  message: Message<T>,
  policy: Policy,
  dependencies: Dependencies<T>,
) {
  if (!Number.isInteger(policy.maxAttempts) || policy.maxAttempts < 1) {
    throw new RangeError("maxAttempts must be a positive integer");
  }

  for (let attempt = 1; attempt <= policy.maxAttempts; attempt += 1) {
    try {
      await dependencies.handler(message);
      return { status: "processed" as const, attempts: attempt };
    } catch (caught) {
      const error = caught instanceof Error
        ? caught
        : new Error(String(caught));
      const retryable =
        !(error instanceof NonRetryableError) &&
        attempt < policy.maxAttempts;

      if (retryable) {
        await dependencies.sleep(
          policy.initialDelayMs * policy.multiplier ** (attempt - 1),
        );
        continue;
      }

      await dependencies.deadLetters.publish({
        original: message,
        attempts: attempt,
        failedAt: dependencies.now().toISOString(),
        error: { name: error.name, message: error.message },
      });
      return { status: "dead-lettered" as const, attempts: attempt };
    }
  }

  throw new Error("unreachable");
}

Injecting the sleeper turns time into assertable data. The executor waits only between calls, never after terminal failure. It awaits DLQ publication before reporting success, which lets the transport adapter avoid acknowledging a message that was never stored.

Production code may cap delays and add jitter. Model both as policy inputs. Disable jitter in unit tests or inject a seeded random source so expected delays remain repeatable.

Verification: Run npx tsc --noEmit. Inspect the boundaries: one maximum attempt produces no wait, non-retryable errors stop immediately, and successful processing never publishes a dead letter.

Step 3: Test Transient Recovery

Create test/retry.test.ts:

import { expect, it, vi } from "vitest";
import type { DeadLetter, Message } from "../src/contracts.js";
import { processWithRetry } from "../src/retry.js";

type Order = { orderId: string; total: number };
const message: Message<Order> = {
  id: "evt-101",
  topic: "orders.created",
  payload: { orderId: "ord-7", total: 49.5 },
  headers: { "correlation-id": "corr-88" },
};
const policy = {
  maxAttempts: 3,
  initialDelayMs: 100,
  multiplier: 2,
};

it("recovers without publishing to the DLQ", async () => {
  const handler = vi.fn()
    .mockRejectedValueOnce(new Error("temporary outage"))
    .mockResolvedValueOnce(undefined);
  const publish = vi.fn<
    (letter: DeadLetter<Order>) => Promise<void>
  >().mockResolvedValue(undefined);
  const sleep = vi.fn().mockResolvedValue(undefined);

  const result = await processWithRetry(message, policy, {
    handler,
    deadLetters: { publish },
    sleep,
    now: () => new Date("2026-07-15T10:00:00.000Z"),
  });

  expect(result).toEqual({ status: "processed", attempts: 2 });
  expect(handler).toHaveBeenCalledTimes(2);
  expect(handler).toHaveBeenNthCalledWith(2, message);
  expect(sleep.mock.calls).toEqual([[100]]);
  expect(publish).not.toHaveBeenCalled();
});

This proves that the identical message is redelivered after one controlled failure. Final success alone is insufficient because an implementation might rebuild or partially lose the original envelope.

Verification: Run npm test. Expect one passing test in milliseconds. Real time must not advance because the sleeper is a mock.

Step 4: Test Dead Letter Queue Retries at the Limit

Add this test:

it("publishes exactly once after exhaustion", async () => {
  const handler = vi.fn()
    .mockRejectedValue(new TypeError("invalid mapping"));
  const publish = vi.fn().mockResolvedValue(undefined);
  const sleep = vi.fn().mockResolvedValue(undefined);

  const result = await processWithRetry(message, policy, {
    handler,
    deadLetters: { publish },
    sleep,
    now: () => new Date("2026-07-15T10:00:00.000Z"),
  });

  expect(result).toEqual({ status: "dead-lettered", attempts: 3 });
  expect(handler).toHaveBeenCalledTimes(3);
  expect(sleep.mock.calls).toEqual([[100], [200]]);
  expect(publish).toHaveBeenCalledTimes(1);
  expect(publish).toHaveBeenCalledWith({
    original: message,
    attempts: 3,
    failedAt: "2026-07-15T10:00:00.000Z",
    error: { name: "TypeError", message: "invalid mapping" },
  });
});

With three attempts, two waits occur. No 400 millisecond wait belongs after terminal failure. The exact envelope check catches lost headers and payload fields that a partial assertion can hide. If the payload contains sensitive data, test an explicit redaction transform instead.

Outcome Handler calls Waits DLQ writes
Immediate success 1 0 0
Recovery on call two 2 1 0
Three failures 3 2 1
Permanent failure 1 0 1

Verification: Run npm test -- --reporter=verbose. Expect both tests to pass. Changing maxAttempts should fail several assertions, proving that the boundary is protected.

Step 5: Test a Poison Message

Import NonRetryableError, then add:

it("does not retry a poison message", async () => {
  const handler = vi.fn().mockRejectedValue(
    new NonRetryableError("missing orderId"),
  );
  const publish = vi.fn().mockResolvedValue(undefined);
  const sleep = vi.fn().mockResolvedValue(undefined);

  const result = await processWithRetry(message, policy, {
    handler,
    deadLetters: { publish },
    sleep,
    now: () => new Date("2026-07-15T10:00:00.000Z"),
  });

  expect(result).toEqual({ status: "dead-lettered", attempts: 1 });
  expect(handler).toHaveBeenCalledTimes(1);
  expect(sleep).not.toHaveBeenCalled();
  expect(publish).toHaveBeenCalledWith(
    expect.objectContaining({
      attempts: 1,
      error: {
        name: "NonRetryableError",
        message: "missing orderId",
      },
    }),
  );
});

Missing required fields and unsupported schema versions are usually permanent. Timeouts, throttling, and temporary outages are normally retryable. Authorization needs deliberate classification because credential refresh may recover, while a forbidden operation may not.

Catch incompatibilities earlier through Avro schema compatibility validation in CI, but retain runtime protection for old events and manual replay.

Verification: Run npm test. Expect three passing tests, with zero sleeper calls for the poison event.

Step 6: Test Configuration Boundaries

Add table-driven validation:

it.each([0, -1, 1.5])(
  "rejects invalid maxAttempts %s",
  async (maxAttempts) => {
    await expect(processWithRetry(
      message,
      { ...policy, maxAttempts },
      {
        handler: vi.fn(),
        deadLetters: { publish: vi.fn() },
        sleep: vi.fn(),
        now: () => new Date(),
      },
    )).rejects.toThrow(
      "maxAttempts must be a positive integer",
    );
  },
);

Also add a one-attempt failure test. Assert one handler call, no sleeper calls, one DLQ write, and attempts: 1. This documents what disabling retry means. Validate environment configuration at application startup so a malformed value is found before consumption begins.

Avoid a setting named only retries. Some libraries count redeliveries after the original, while others expose total deliveries. Translate broker-specific counters at the adapter boundary and keep the domain policy unambiguous.

Verification: Run npm test. Vitest expands the invalid values into three cases. Confirm that the handler and publisher never run for invalid configuration.

Step 7: Test DLQ Publication Failure

Add this terminal safety test:

it("rejects when DLQ publication fails", async () => {
  const handler = vi.fn()
    .mockRejectedValue(new Error("handler down"));
  const publish = vi.fn()
    .mockRejectedValue(new Error("DLQ unavailable"));

  await expect(processWithRetry(
    message,
    { ...policy, maxAttempts: 1 },
    {
      handler,
      deadLetters: { publish },
      sleep: vi.fn(),
      now: () => new Date(),
    },
  )).rejects.toThrow("DLQ unavailable");

  expect(handler).toHaveBeenCalledTimes(1);
  expect(publish).toHaveBeenCalledTimes(1);
});

The rejected promise tells the adapter not to acknowledge or commit the source delivery. A broker can redeliver, so make DLQ publication idempotent. A useful deduplication key combines original message ID, consumer identity, and failure generation.

In RabbitMQ, verify nack or the absence of acknowledgment. In Kafka, do not commit the source offset before the DLQ producer confirms its write. The order is a data-loss boundary, not merely an implementation detail.

Verification: Run npm test. If the executor returns dead-lettered instead of rejecting, it claims success before durable publication and the test must fail.

Step 8: Add One Broker-Backed Test

Keep the full matrix deterministic, then add one test through the real broker adapter. Use a unique destination and consumer group per run. Publish one always-failing message, poll by message ID with a bounded timeout, and clean up even after assertion failure.

Verify real routing, serialization, headers, retry count, exactly one DLQ record, and source settlement only after DLQ confirmation. Run twice to expose leaked state. Do not reproduce every unit combination in containers because that adds time without equivalent transport confidence.

For Kafka, continue with Kafka consumer contract tests step by step. For queue routing, use RabbitMQ event mocking for integration tests.

Verification: Two consecutive runs should each read exactly one matching dead letter and no record from a prior run.

Step 9: Verify Idempotency, Ordering, and Observability

Retry correctness does not end when the handler returns. A handler can complete its business write and fail before acknowledgment, causing the same event to arrive again. Build the consumer around an idempotency key, usually the producer's stable message ID, and store that key in the same transaction as the business change when your data platform supports it.

Test duplicate delivery by calling the handler twice with the same message. The second call should return safely without creating another order, payment, email, or inventory adjustment. Do not treat a duplicate as an exceptional DLQ condition. Duplicate delivery is normal in at-least-once systems, especially after consumer restarts and network failures.

Ordering needs a separate decision. If events for one aggregate must stay ordered, a poison message can block later records in the same partition or queue. Your policy might stop the partition, park the failed event and continue, or route all later records for that key to a holding area. Each choice trades availability for ordering guarantees. Write a test with two messages for the same order, make the first fail, and assert the documented behavior of the second.

Observability is also a contract. Capture retry attempt, maximum attempts, message ID, source, consumer name, error category, and correlation or trace ID in structured logs. Emit counters for attempts, recovered messages, exhausted messages, non-retryable messages, and DLQ publication failures. Measure message age so a small but permanently stuck backlog is visible.

Never assert volatile timestamps or complete log strings. Inject the clock and assert structured fields. For metrics, use an in-memory recorder and verify a recovery increments both the retry and recovery counters, while exhaustion increments retry, exhaustion, and DLQ publication counters exactly once at their proper stages.

Verification: Deliver the same message twice and confirm that the business side effect occurs once. Then fail a message permanently and confirm one terminal metric, one DLQ record, and a structured diagnostic event containing the same message and correlation IDs.

Choose the Right Test Layer

A useful suite separates policy, adapter, and end-to-end responsibilities. Policy tests run without a broker and cover every branch. Adapter tests exercise the consumer library with a local or ephemeral broker. A small end-to-end test proves the deployed producer, broker policy, consumer, DLQ destination, permissions, and monitoring work together.

Test layer Main purpose Typical scope Keep deterministic by
Policy unit test Retry decisions and envelope Dozens of cases Injecting sleep, clock, and errors
Adapter integration test Client and broker semantics A few failure paths Unique destinations and polling
End-to-end test Deployed wiring and permissions One critical path Correlation IDs and bounded waits

Do not use an end-to-end test to enumerate all delay combinations. It will be slow and failures will be hard to diagnose. Do not rely only on unit tests either, because a correct loop cannot prove that a Kafka offset, RabbitMQ delivery tag, or SQS receipt handle is settled correctly.

For cloud services, verify infrastructure policy separately. Check DLQ permissions, retention, redrive limits, encryption, and alarms as infrastructure configuration. Then use one live smoke test in a controlled environment to prove the service identity can publish and operations can find the event.

Verification: Label tests by layer and run the unit suite without network access. Run adapter tests against isolated resources. Confirm the end-to-end test fails clearly if the DLQ permission is deliberately removed in a disposable environment, then restore the permission.

Design Retry Backoff Assertions

Exponential backoff reduces pressure on an unhealthy dependency, but uncapped growth can make recovery unreasonably slow. A practical policy often combines initial delay, multiplier, maximum delay, and jitter. Test the pure delay calculation independently from message processing.

For example, an initial delay of 100 milliseconds, multiplier 2, and cap 1,000 produces 100, 200, 400, 800, 1,000, and 1,000. Assert the sequence directly. For full jitter, the selected delay should fall between zero and the capped value. Inject a random function so boundary values can be tested without probabilistic failures.

Respect server guidance where appropriate. An HTTP dependency may return a Retry-After value that should override or raise the local delay. Test valid seconds, valid dates, malformed values, and a value beyond the maximum policy. Never let an untrusted header create an unlimited wait.

Cancellation matters during shutdown. A consumer should stop waiting when its abort signal fires and avoid starting another handler call. Add a cancellation test if graceful shutdown is part of the adapter. Confirm that cancellation does not publish a misleading poison event unless your operational policy explicitly requires parking in-flight work.

Keep timing assertions logical rather than wall-clock based. A test that expects completion within 110 milliseconds will fail on a loaded runner even when the algorithm is correct. Assert requested durations through the injected scheduler, and use generous bounded polling only at the broker boundary.

Verification: Feed six attempts into the delay function and compare the exact capped sequence. Inject minimum and maximum random values for jitter. Abort a pending sleep and confirm that neither another handler call nor a terminal DLQ write occurs without an intentional shutdown policy.

Plan Safe Dead-Letter Replay

A DLQ without a replay process becomes permanent storage for unresolved defects. Define who may replay, what validation occurs first, whether the original destination is reused, and how replay activity is audited. Replay tooling should support filtering by message ID, error category, producer version, and time range.

Fix or quarantine the underlying cause before replaying a batch. Validate the stored schema and apply an explicit migration if the current consumer no longer accepts the old representation. Preserve original IDs for idempotency, while adding replay metadata such as generation, reason, initiator, and timestamp.

Test replay with three cases. First, a corrected transient event succeeds and leaves no new DLQ entry. Second, replaying an already processed ID creates no duplicate business effect. Third, a still-invalid event returns to controlled quarantine with an incremented replay generation and stops at the configured generation limit.

Rate-limit batch replay because old messages can overwhelm a recovered dependency or overtake fresh traffic. If ordering is important, replay by partition key and document how live consumption interacts with the batch. Provide a dry-run mode that validates and counts candidates without publishing them.

Access control deserves a test too. An operator with read-only access should inspect records but not replay them. An authorized replay identity should publish only to approved destinations. Audit events should record selection criteria and counts without copying sensitive payload contents into logs.

Verification: Replay one known message twice and assert a single business side effect. Attempt replay with a read-only identity and expect denial. Replay an invalid record up to its generation limit and confirm that the tool stops instead of creating an infinite loop.

Troubleshooting

Tests take as long as production backoff -> Inject the sleeper and assert requested delays as values.

Three retries produce four calls -> Decide whether the setting means retries or total attempts. Prefer maxAttempts.

The test intermittently finds no DLQ event -> Poll by message ID with a timeout instead of using a fixed sleep.

The source disappears when DLQ publication fails -> Move acknowledgment or offset commit after confirmed publication.

Parallel tests interfere -> Use unique destinations and consumer groups. Never purge a shared queue owned by another process.

A poison event loops forever -> Separate DLQ storage from replay, record replay generation, and enforce a replay limit.

Where To Go Next

Return to the complete event-driven API testing guide, then add Kafka consumer contract testing, Avro compatibility checks, or RabbitMQ integration event mocks.

Then strengthen the surrounding suite with API integration testing techniques. Add observability assertions next. A terminal failure should emit a metric and structured log with message ID, source, attempts, error category, and trace ID, without leaking sensitive payload data.

Interview Questions and Answers

Q: What must a retry test verify?

Total attempts, delay sequence, classification, exact DLQ envelope, and settlement. It must also prove recovery avoids DLQ publication.

Q: Why inject a sleeper?

It turns delays into assertable values, avoids real waits, and decouples tests from timer implementation.

Q: How do you verify exponential backoff?

Compare ordered requested delays with the policy formula and assert no delay after terminal failure.

Q: What is a poison message?

It has a permanent content or contract problem. It usually bypasses retry and retains diagnostic context.

Q: When should the source be acknowledged?

After processing succeeds or confirmed DLQ publication succeeds. Publication failure must propagate.

Q: Why add a broker test?

It proves routing, serialization, headers, settlement, and client configuration that mocks cannot prove.

Best Practices

  • Make attempt meaning explicit.
  • Inject time and randomness.
  • Assert ordered delays and exact envelopes.
  • Preserve identifiers needed for diagnosis and replay.
  • Redact secrets and regulated data.
  • Monitor exhaustion, recovery, DLQ failure, and message age.
  • Make publishing and replay idempotent.
  • Give the DLQ ownership, retention, access controls, and a runbook.

Common Mistakes

Checking only DLQ existence misses off-by-one calls, incorrect backoff, lost headers, duplicates, and premature acknowledgment. Retrying every exception wastes capacity on permanent validation errors. Classifying every failure as permanent sends recoverable events to manual remediation.

Do not rely only on snapshots. Targeted assertions document required operational fields. Use API contract testing best practices to keep those expectations explicit across service boundaries. Do not assume broker delivery count equals application attempts because reconnects, rebalances, and visibility timeouts affect transport delivery.

Conclusion

To test dead letter queue retries, treat retry policy and the dead-letter envelope as public contracts. Drive transient and permanent failures, assert each attempt and delay, and prove recovery never reaches the DLQ.

Keep the broad matrix deterministic, then add one focused broker test for routing and settlement. This combination catches policy regressions quickly while protecting the real data-loss boundary.

Interview Questions and Answers

How would you test DLQ retry behavior?

I would inject controlled failures and assert total calls, ordered delays, final state, and the exact envelope. I would cover recovery, exhaustion, poison messages, and publisher failure, then add one broker test.

What is retry count versus attempt count?

Attempt count includes initial delivery. Retry count often counts only later calls, so I prefer an explicit maxAttempts policy.

How do you keep backoff tests deterministic?

Inject the sleeper and record requested delays. Disable or seed jitter and assert no delay follows terminal failure.

How should DLQ publication failure behave?

It must propagate so the source is not acknowledged or committed. Publication should be idempotent because redelivery may repeat it.

Which failures bypass retry?

Known permanent failures such as invalid required fields or unsupported schemas. Temporary timeouts and outages usually remain retryable.

What does a broker test add?

It proves real routing, serialization, headers, redelivery, and settlement behavior that an in-memory test cannot prove.

Frequently Asked Questions

How do you test dead letter queue retries?

Drive controlled handler failures, capture delays, and assert attempts, final status, and the DLQ envelope. Cover recovery, exhaustion, poison messages, and publication failure.

Should retry tests use real delays?

Most should inject a sleeper or clock. Keep real timing limited to a focused adapter test.

Does max retries include the first attempt?

It is ambiguous. Prefer maxAttempts and define it as total handler calls including initial delivery.

What belongs in a dead-letter message?

Preserve safe original data, source, identifiers, total attempts, failure time, and normalized error details.

Should poison messages be retried?

Usually no. Known permanent validation and compatibility failures should bypass retries.

How do you prevent duplicate DLQ messages?

Use an idempotency key based on original message ID and consumer identity, and acknowledge only after confirmed publication.

Related Guides