Skip to content

Allow specific exception types to bypass the dead letter queue - #353

Merged
nabisobhi merged 5 commits into
masterfrom
nabisobhi-bookish-waddle
Sep 10, 2026
Merged

Allow specific exception types to bypass the dead letter queue#353
nabisobhi merged 5 commits into
masterfrom
nabisobhi-bookish-waddle

Conversation

@nabisobhi

@nabisobhi nabisobhi commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Problem

When a dead letter queue is configured, every exception from a message handler is dead-lettered once retries are exhausted. That's the wrong behavior for fatal or systemic failures.

If a shared dependency goes down (database unreachable, bad configuration, a bug that fails on every message), every message fails, every message gets dead-lettered, and offsets keep committing. The result is that an entire topic quietly drains into the dead letter queue in seconds, and the natural backpressure you'd get from a failing consumer is lost.

Change

Adds an opt-in bypass so selected exception types skip the dead letter queue entirely. A matching exception is not retried and not dead-lettered — it propagates out of message dispatch, and Dafda does not commit the offset for the message.

services.AddConsumer(options =>
{
    options.WithDeadLetterQueue("orders.dead-letter")
           .WithMaxRetries(3)
           .BypassFor<MyFatalException>()         // by exception type (incl. derived)
           .BypassWhen(ex => ex is DbException);  // or by predicate
});

Multiple bypass registrations are OR'd together. When none are registered, behavior is unchanged.

What happens after the bypass

The exception escapes Consumer.ConsumeAll and is handled by ConsumerHostedService, which routes it through the configured IConsumerErrorHandler:

  • Default handlerConsumerFailureStrategy.Default stops the application, and the orchestrator restarts it. This is the intended failure mode.
  • RestartConsumer — the consumer is restarted and the redelivered message fails again, in a tight loop. If you configure a restart strategy via WithConsumerErrorHandler, it needs to back off.

The bypass deliberately does not override the configured error handler, since that's an explicit user choice.

Commit semantics

Redelivery of a bypassed message requires manual commits (enable.auto.commit=false). Dafda only commits the offset itself in that mode. With automatic commits — the default — the Kafka client stores offsets as messages are consumed and commits them on its interval and on close, so a bypassed message may still be marked as consumed.

This is a property of Dafda's existing auto-commit mode rather than something the bypass introduces: on master today, an exception from a handler in a consumer with no dead letter queue configured already propagates past the explicit commit in the same way. Closing that gap properly (e.g. enable.auto.offset.store=false plus an explicit store after successful handling) is worth its own change and is not attempted here. The behavior is documented on the public API instead.

Implementation

The bypass is evaluated in the existing exception filter in Consumer.Dispatch, so a matching exception never enters the retry/dead-letter path at all:

catch (Exception exception) when (deadLetterQueueEnabled
    && !cancellationToken.IsCancellationRequested
    && !ShouldBypassDeadLetterQueue(exception))

The predicate is threaded from DeadLetterQueueOptions through ConsumerConfigurationBuilderConsumerConfigurationConsumer, following the same path as the existing MaxRetries setting. Both AddConsumer overloads are wired. The registered predicates are snapshotted when the combined delegate is composed, so configuration is fixed at build time.

API surface

Additive only. Two new public methods on DeadLetterQueueOptions (itself new in the unreleased 2.1.0-beta1); everything else touched is internal. No new dependencies, no default behavior change. Minor version bump.

Prior art

This mirrors how other Kafka frameworks separate poison messages from systemic failures — Spring Kafka's DefaultErrorHandler with addNotRetryableExceptions(...) plus ContainerStoppingErrorHandler, and Kafka Connect's errors.tolerance = none. Fail fast on fatal errors; dead-letter only genuine poison messages.

Tests

Coverage for a bypassed exception propagating without being retried, dead-lettered, or having its offset committed by Dafda; for non-matching exceptions still being dead-lettered as before; and for predicate composition and snapshotting. Full suite: 160 passing, build clean with 0 warnings.

Notes for reviewers

This is the first of three related PRs. The other two stack on this one and should be reviewed after it:

Follow-ups worth discussing separately, none of them changing default behavior in this PR: at-least-once commit semantics under auto-commit (above), a "stop this consumer only" option like Spring's ContainerStoppingErrorHandler, and a hard stop that a RestartConsumer strategy can't override.

Adds a configurable bypass predicate so fatal/systemic exceptions propagate
and crash the consumer instead of being retried or dead-lettered. This
prevents a systemic outage (e.g. database down) from silently draining an
entire topic into the dead letter queue.

New fluent API on DeadLetterQueueOptions:
  .BypassFor<TException>()
  .BypassWhen(ex => ...)

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds an opt-in “dead letter queue bypass” so selected exception types (or predicates) skip the retry/dead-letter path and instead propagate out of message dispatch, enabling fail-fast behavior for systemic/fatal failures.

Changes:

  • Added a DLQ bypass hook to Consumer.Dispatch so matching exceptions do not retry and do not dead-letter.
  • Introduced fluent configuration (BypassFor<TException>(), BypassWhen(...)) on DeadLetterQueueOptions and threaded the resulting predicate through configuration → DI wiring → Consumer.
  • Added/updated tests and test builders to cover bypass vs non-bypass behavior.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Dafda/Consuming/Consumer.cs Adds bypass predicate plumbing and uses it in the DLQ exception filter.
src/Dafda/Configuration/DeadLetterQueueOptions.cs Adds fluent API to register bypass exception types/predicates and composes them into a single predicate.
src/Dafda/Configuration/ConsumerServiceCollectionExtensions.cs Wires the bypass predicate into Consumer construction for both AddConsumer overloads.
src/Dafda/Configuration/ConsumerConfigurationBuilder.cs Extracts bypass predicate from DLQ options into built configuration.
src/Dafda/Configuration/ConsumerConfiguration.cs Stores the bypass predicate on the built configuration object.
src/Dafda.Tests/Consuming/TestConsumer.cs Adds tests for bypassed exceptions propagating and non-matching exceptions still dead-lettering.
src/Dafda.Tests/Builders/ConsumerBuilder.cs Adds builder support for passing a DLQ bypass predicate into Consumer.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Dafda/Configuration/DeadLetterQueueOptions.cs Outdated
Comment thread src/Dafda/Consuming/Consumer.cs
Comment thread src/Dafda.Tests/Consuming/TestConsumer.cs
nabisobhi and others added 3 commits August 30, 2026 21:12
BypassPredicate returned a delegate closing over the mutable backing list,
so predicates registered after the configuration was built would change the
behavior of an already running consumer. Enumerating the live list from the
consumer thread could also throw a collection-modified exception from inside
the error handling path. The predicates are now copied when the combined
delegate is composed, matching the other options which are read by value at
build time.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Skipping the commit is what makes a bypassed message redelivered after the
consumer restarts, so the bypass test now uses an onCommit spy to assert it,
rather than only checking that the message was neither retried nor sent to
the dead letter queue.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The documentation claimed a bypassed exception crashes the consumer. It is
in fact rethrown out of message dispatch without committing the offset, and
then routed through the configured consumer error handler: the default
strategy stops the application, but RestartConsumer restarts the consumer and
the redelivered message fails again. Document the propagation, the skipped
commit and the restart caveat instead.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment thread src/Dafda/Consuming/Consumer.cs
The bypass documentation promised the message would be redelivered because
the offset had not been committed. That only holds when enable.auto.commit is
false: Dafda skips its explicit commit, but the Kafka client stores offsets as
messages are consumed and commits them on its interval and on close, so a
bypassed message can still be marked as consumed.

This is a property of the existing auto-commit mode rather than something the
bypass introduces, and it applies equally to an exception escaping a consumer
with no dead letter queue configured. Only the redelivery claim was wrong, so
narrow it to the manual commit case instead of changing the commit behaviour.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

@joseban-iii joseban-iii left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks Legit! 👍

@nabisobhi
nabisobhi merged commit 0a80a4d into master Sep 10, 2026
4 checks passed
@nabisobhi
nabisobhi deleted the nabisobhi-bookish-waddle branch September 10, 2026 12:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants