Skip to content

Add IDeadLetterInterceptor: an exception-handling seam for durable dead letters#3046

Merged
jeremydmiller merged 4 commits into
mainfrom
feat/dead-letter-interceptor
Jun 7, 2026
Merged

Add IDeadLetterInterceptor: an exception-handling seam for durable dead letters#3046
jeremydmiller merged 4 commits into
mainfrom
feat/dead-letter-interceptor

Conversation

@mysticmind

Copy link
Copy Markdown
Member

Add IDeadLetterInterceptor, a hook invoked before an envelope is written to durable dead-letter storage. Lets you scrub or replace the exception recorded with a dead letter (handlers sometimes put sensitive data in exception messages, which are stored outside the serializer) and optionally mutate the body. Registered via DI, runs in order chaining the returned exception, no-op when unused, durable path only.

IDeadLetterExceptionInfo (opt-in) lets a replacement exception keep the original exception_type while redacting the message, so type-based triage still works.

Invoke registered IDeadLetterInterceptor implementations for each envelope just
before it is written to durable dead-letter storage, at the single
MessageContext.MoveToDeadLetterQueueAsync chokepoint. Interceptors may mutate the
envelope (for example to redact or encrypt the body) and/or return a replacement
exception to persist; they run in registration order, each receiving the exception
returned by the previous one.

This is a transformation seam for durable dead letters - useful for redacting PII
or secrets, encrypting dead-letter payloads at rest, or scrubbing sensitive
exception text before storage. It is resolved from the application container and
is a no-op (zero overhead, no behavior change) when none are registered. Not
invoked for broker-native dead-lettering.
Add IDeadLetterExceptionInfo so an exception can declare the exception type and
message written to dead-letter storage independently of its runtime type, resolved
through DeadLetterExceptionExtensions at each store's capture site (RDBMS, RavenDB,
Oracle, Cosmos DB).

Together with IDeadLetterInterceptor this lets a redacting interceptor scrub the
exception message while preserving the original exception type, so operators keep
the ability to filter and triage dead letters by type. Behavior is unchanged for
exceptions that do not implement the marker.
Clarify that the hook's distinctive use is scrubbing or replacing the exception
recorded with a dead letter (which is stored outside the message serializer), and
that encrypting or redacting the message body at rest is usually better done with a
custom IMessageSerializer, which also covers the wire and the durable inbox/outbox.
The hook can still mutate the body for dead-letter-only transforms.
The remarks recommended a custom IMessageSerializer over this hook for body-at-rest
encryption. That is editorial steering toward a different feature and a tradeoff
judgement that does not belong in the interface contract; it fits the PR discussion
instead. Keep the factual note that the hook can mutate the envelope body.
Copilot AI review requested due to automatic review settings June 7, 2026 18:45

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 introduces a new extensibility seam for durable dead-letter persistence, allowing applications to intercept a failing envelope just before it’s written to Wolverine’s database-backed dead-letter storage so they can redact/replace exception info (and optionally mutate the envelope).

Changes:

  • Add IDeadLetterInterceptor (DI-resolved, ordered) invoked before persisting to durable dead-letter storage.
  • Add IDeadLetterExceptionInfo + DeadLetterExceptionExtensions to decouple persisted exception type/message from the runtime exception type/message.
  • Update durable dead-letter persistence implementations (RDBMS, Oracle, RavenDB, CosmosDB) to persist exception strings via the new extension methods; add core tests for chaining + exception info behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/Wolverine/Runtime/MessageContext.cs Adds interceptor invocation before calling durable dead-letter storage (for the non-native DLQ path).
src/Wolverine/Persistence/Durability/IDeadLetterInterceptor.cs Introduces the interceptor contract + documentation for redaction/mutation use cases.
src/Wolverine/Persistence/Durability/IDeadLetterExceptionInfo.cs Adds opt-in exception info contract + helpers to compute persisted type/message.
src/Testing/CoreTests/ErrorHandling/dead_letter_interceptor.cs Tests that interceptors run in order and chain the exception result.
src/Testing/CoreTests/ErrorHandling/dead_letter_exception_info.cs Tests fallback behavior + IDeadLetterExceptionInfo override behavior.
src/Persistence/Wolverine.RDBMS/DatabasePersistence.cs Uses DeadLetterExceptionType/Message() for persisted exception strings.
src/Persistence/Wolverine.RavenDb/Internals/DeadLetterMessage.cs Uses DeadLetterExceptionType/Message() when building Raven dead-letter docs.
src/Persistence/Wolverine.CosmosDb/Internals/DeadLetterMessage.cs Uses DeadLetterExceptionType/Message() when building Cosmos dead-letter docs.
src/Persistence/Oracle/Wolverine.Oracle/OracleMessageStore.Incoming.cs Uses DeadLetterExceptionType/Message() for Oracle dead-letter persistence.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +396 to +404
var ex = await interceptDeadLetterAsync(envelope, exception).ConfigureAwait(false);
await Storage.Inbox.MoveToDeadLetterStorageAsync(envelope, ex).ConfigureAwait(false);
}
}
else
{
// If persistable, persist
await Storage.Inbox.MoveToDeadLetterStorageAsync(Envelope, exception).ConfigureAwait(false);
var ex = await interceptDeadLetterAsync(Envelope, exception).ConfigureAwait(false);
await Storage.Inbox.MoveToDeadLetterStorageAsync(Envelope, ex).ConfigureAwait(false);
Comment on lines +415 to +424
private async ValueTask<Exception?> interceptDeadLetterAsync(Envelope envelope, Exception? exception)
{
foreach (var interceptor in Runtime.Services.GetServices<IDeadLetterInterceptor>())
{
exception = await interceptor.BeforeStoreAsync(envelope, exception, Runtime.Cancellation)
.ConfigureAwait(false);
}

return exception;
}
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