Allow specific exception types to bypass the dead letter queue - #353
Merged
Conversation
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>
This was referenced Aug 30, 2026
There was a problem hiding this comment.
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.Dispatchso matching exceptions do not retry and do not dead-letter. - Introduced fluent configuration (
BypassFor<TException>(),BypassWhen(...)) onDeadLetterQueueOptionsand 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.
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Multiple bypass registrations are OR'd together. When none are registered, behavior is unchanged.
What happens after the bypass
The exception escapes
Consumer.ConsumeAlland is handled byConsumerHostedService, which routes it through the configuredIConsumerErrorHandler:ConsumerFailureStrategy.Defaultstops 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 viaWithConsumerErrorHandler, 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
mastertoday, 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=falseplus 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:The predicate is threaded from
DeadLetterQueueOptionsthroughConsumerConfigurationBuilder→ConsumerConfiguration→Consumer, following the same path as the existingMaxRetriessetting. BothAddConsumeroverloads 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 unreleased2.1.0-beta1); everything else touched isinternal. 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
DefaultErrorHandlerwithaddNotRetryableExceptions(...)plusContainerStoppingErrorHandler, and Kafka Connect'serrors.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 aRestartConsumerstrategy can't override.