Date: 2026-02-23
Accepted
Parent Requirement: specs/0022-Defer-Message-Action-Backstop-Handler/requirements.md
Scope: This ADR covers the design of the DeferMessageOnError attribute/handler pair and changes to DeferMessageAction needed to carry a delay value from the handler pipeline to the message pump.
Brighter's handler pipeline uses exception-based flow control to signal the message pump how to dispose of a message after handler execution. Three action exceptions exist:
| Exception | Pump Behavior |
|---|---|
RejectMessageAction |
Reject message → DLQ |
DontAckAction |
Leave message unacknowledged → transport re-delivers |
DeferMessageAction |
Requeue message with delay → retry later |
Declarative backstop attributes already exist for RejectMessageAction (RejectMessageOnErrorAttribute, spec 0002) and DontAckAction (DontAckOnErrorAttribute, spec 0020). These attributes wrap the inner pipeline in a try/catch and throw the corresponding action exception when any unhandled exception escapes.
DeferMessageAction is the only action that lacks a declarative backstop attribute. Developers must currently write manual try/catch blocks in every handler that needs deferred retry behavior. This ADR addresses that gap and also resolves a design question: how to carry a configurable delay from the attribute through to the pump's requeue call.
When the pump catches DeferMessageAction, it calls RequeueMessage(), which uses the subscription-level RequeueDelay:
catch (DeferMessageAction)
{
if (await RequeueMessage(message)) continue;
}
// RequeueMessage ultimately calls:
Channel.RequeueAsync(message, RequeueDelay); // RequeueDelay from subscription config
Today, DeferMessageAction is a bare exception with no properties:
public class DeferMessageAction : Exception;This means all deferred messages use the same delay regardless of handler context. The attribute should allow per-handler delay configuration.
Create four new types mirroring the structure of RejectMessageOnError and DontAckOnError:
src/Paramore.Brighter/
Defer/
Attributes/
DeferMessageOnErrorAttribute.cs → GetHandlerType() returns DeferMessageOnErrorHandler<>
DeferMessageOnErrorAsyncAttribute.cs → GetHandlerType() returns DeferMessageOnErrorHandlerAsync<>
Handlers/
DeferMessageOnErrorHandler.cs → catch (Exception) → throw DeferMessageAction
DeferMessageOnErrorHandlerAsync.cs → catch (Exception) → throw DeferMessageAction
Roles and responsibilities:
DeferMessageOnErrorAttribute(and async variant): Coordinator — declares pipeline position and delay configuration; passes delay to handler viaInitializerParams()DeferMessageOnErrorHandler<TRequest>(and async variant): Service Provider — wraps inner pipeline in try/catch; on exception, logs diagnostics and throwsDeferMessageActionwith the configured delay and original exception
The attribute accepts step (pipeline position) and delayMilliseconds (delay before requeue):
[AttributeUsage(AttributeTargets.Method)]
public class DeferMessageOnErrorAttribute : RequestHandlerAttribute
{
private readonly int _delayMilliseconds;
public DeferMessageOnErrorAttribute(int step, int delayMilliseconds = 0)
: base(step, HandlerTiming.Before)
{
_delayMilliseconds = delayMilliseconds;
}
public override object[] InitializerParams()
=> [_delayMilliseconds];
public override Type GetHandlerType()
=> typeof(DeferMessageOnErrorHandler<>);
}The handler receives the delay via InitializeFromAttributeParams:
public partial class DeferMessageOnErrorHandler<TRequest> : RequestHandler<TRequest>
where TRequest : class, IRequest
{
private int _delayMilliseconds;
public override void InitializeFromAttributeParams(params object?[] initializerList)
{
_delayMilliseconds = (int?)initializerList[0] ?? 0;
}
public override TRequest Handle(TRequest request)
{
try
{
return base.Handle(request);
}
catch (Exception ex)
{
Log.UnhandledExceptionDeferringMessage(s_logger, ex, typeof(TRequest).Name, ex.Message);
throw new DeferMessageAction(ex.Message, ex, _delayMilliseconds);
}
}
}This follows the same InitializerParams pattern used by TimeoutPolicyAttribute (which passes _milliseconds to TimeoutPolicyHandler).
DeferMessageAction gains explicit constructors (matching RejectMessageAction and DontAckAction) and a Delay property:
public class DeferMessageAction : Exception
{
public DeferMessageAction() { }
public DeferMessageAction(string? reason) : base(reason) { }
public DeferMessageAction(string? reason, Exception? innerException) : base(reason, innerException) { }
public DeferMessageAction(string? reason, Exception? innerException, int delayMilliseconds)
: base(reason, innerException)
{
Delay = TimeSpan.FromMilliseconds(delayMilliseconds);
}
/// <summary>
/// The requested delay before requeue. If null, the pump uses the subscription's RequeueDelay.
/// </summary>
public TimeSpan? Delay { get; }
}Design choices:
TimeSpan?notint: Avoids primitive obsession;TimeSpanis the idiomatic .NET type for durations and matchesRequeueDelayon the subscription and pump which are alreadyTimeSpan- Nullable:
nullmeans "use the subscription default"; a non-null value overrides it. This preserves backward compatibility — existing code that throwsnew DeferMessageAction()without a delay continues to use the subscription'sRequeueDelay - Constructor accepts
int delayMilliseconds: Matches the attribute parameter type (attributes can only use compile-time constants, soTimeSpanis not allowed as an attribute parameter); conversion toTimeSpanhappens at construction time
The Reactor and Proactor already catch DeferMessageAction. A small change to RequeueMessage allows the exception's delay to override the subscription default. Two approaches were considered:
Option A — Pass delay through to RequeueMessage: The pump's catch (DeferMessageAction) block extracts the delay and passes it:
catch (DeferMessageAction deferAction)
{
Log.DeferringMessage2(...);
span?.SetStatus(...);
var delay = deferAction.Delay ?? RequeueDelay;
if (await RequeueMessage(message, delay)) continue;
}Option B — No pump change: Ignore the delay on the exception; always use subscription RequeueDelay.
We choose Option A because:
- The whole point of the
delayMillisecondsattribute parameter is per-handler delay control - The change is minimal (one line to extract the delay, pass it through)
- Backward compatible: existing
throw new DeferMessageAction()hasDelay = null, so the pump falls back to subscription default - Both Reactor and Proactor need the same small change
The RequeueMessage method gains an optional delay parameter (defaulting to the subscription's RequeueDelay) to avoid breaking the existing call from any other code path:
// Proactor
private Task<bool> RequeueMessage(Message message, TimeSpan? delay = null)
{
// ... existing handled count logic ...
return Channel.RequeueAsync(message, delay ?? RequeueDelay);
}
// Reactor
private bool RequeueMessage(Message message, TimeSpan? delay = null)
{
// ... existing handled count logic ...
return Channel.Requeue(message, delay ?? RequeueDelay);
}The handler uses source-generated logging matching the pattern from RejectMessageOnErrorHandler:
private static partial class Log
{
[LoggerMessage(EventId = 0, Level = LogLevel.Error,
Message = "Unhandled exception caught by backstop error handler, deferring message for request {RequestType}: {ExceptionMessage}")]
public static partial void UnhandledExceptionDeferringMessage(
ILogger logger, Exception ex, string requestType, string exceptionMessage);
} Handler Pipeline (user code)
┌─────────────────────────────────────────────────────┐
│ [DeferMessageOnError(step:0, delayMilliseconds:5000)] │
│ ┌─────────────────────────────────────────────────┐│
│ │ [UsePolicy("Retry", step:2)] ││
│ │ ┌─────────────────────────────────────────────┐││
│ │ │ MyHandler.Handle(request) │││
│ │ │ throws SomeException │││
│ │ └─────────────────────────────────────────────┘││
│ │ Retry catches, retries, eventually rethrows ││
│ └─────────────────────────────────────────────────┘│
│ DeferMessageOnErrorHandler catches SomeException │
│ logs, throws DeferMessageAction(delay: 5000ms) │
└─────────────────────────────────────────────────────┘
│
▼
Message Pump (Reactor / Proactor)
┌─────────────────────────────────────────────────────┐
│ catch (DeferMessageAction deferAction) │
│ delay = deferAction.Delay ?? RequeueDelay │
│ RequeueMessage(message, delay) │
│ → Channel.Requeue(message, delay: 5000ms) │
└─────────────────────────────────────────────────────┘
| Component | Role | Responsibility |
|---|---|---|
DeferMessageOnErrorAttribute |
Coordinator | Declares pipeline position; passes delayMilliseconds to handler via InitializerParams() |
DeferMessageOnErrorAsyncAttribute |
Coordinator | Async equivalent of above |
DeferMessageOnErrorHandler<T> |
Service Provider | Wraps pipeline in try/catch; converts exceptions to DeferMessageAction with delay |
DeferMessageOnErrorHandlerAsync<T> |
Service Provider | Async equivalent of above |
DeferMessageAction |
Information Holder | Carries delay value and original exception from handler to pump |
Reactor.RequeueMessage |
Service Provider | Accepts optional delay override, falls back to subscription RequeueDelay |
Proactor.RequeueMessage |
Service Provider | Async equivalent of above |
- Structural change (tidy first): Add constructors and
Delayproperty toDeferMessageAction— no behavioral change, existing code continues to work - Behavioral change: Create
Defer/Attributes/andDefer/Handlers/with the four new types - Behavioral change: Update Reactor and Proactor
catch (DeferMessageAction)blocks andRequeueMessagemethods to read and pass the delay - Tests: Unit tests for each handler (sync/async), verifying exception conversion, logging, delay propagation, and happy-path passthrough
- Completes the set of three declarative backstop attributes — consistent API for all error handling actions
- Per-handler delay configuration without modifying subscription-level settings
- Fully backward compatible: existing
throw new DeferMessageAction()without delay continues to use subscription default - Minimal code: each handler is ~20 lines following an established pattern
- Pump changes are small and isolated (one extraction + one parameter pass)
- Small change to Reactor and Proactor (low risk but touches core pump code)
delayMillisecondsusesintin the attribute (attribute limitation) butTimeSpaneverywhere else — minor ergonomic asymmetry- If both the attribute delay and subscription
RequeueDelayare set, the attribute wins — users must understand the override semantics
- Risk: Changing
RequeueMessagesignature could break other callers- Mitigation: The
delayparameter is optional withnulldefault; existing callers are unaffected
- Mitigation: The
- Risk: Pump changes introduce regressions
- Mitigation: Existing pump tests for
DeferMessageActionverify the current behavior; new tests verify the delay override path
- Mitigation: Existing pump tests for
Simpler (no pump changes), but makes the attribute's delayMilliseconds parameter misleading — it would exist on the attribute but have no effect on actual requeue delay. Rejected because it violates the principle of least surprise.
The handler could write the delay to Context.Bag and the pump could read it from there. Rejected because:
- The context bag is a general-purpose dictionary with stringly-typed keys — fragile
- The exception is the natural carrier for this information (it flows directly from handler to pump)
- The other action exceptions (
RejectMessageAction,DontAckAction) carry data as exception properties, not via context bag
Not possible — C# attributes only allow compile-time constant types (primitives, strings, Type, enums). TimeSpan is a struct and cannot be used as an attribute constructor parameter. The conversion from int delayMilliseconds to TimeSpan happens inside the handler/exception constructor.
- Requirements: specs/0022-Defer-Message-Action-Backstop-Handler/requirements.md
- Related ADRs:
- ADR 0006: Blocking and Non-Blocking Retries
- ADR 0037: Universal Scheduler Delay
- ADR 0038: Don't Ack Action
- Existing patterns:
RejectMessageOnErrorAttribute/RejectMessageOnErrorHandler(spec 0002)DontAckOnErrorAttribute/DontAckOnErrorHandler(spec 0020)TimeoutPolicyAttribute/TimeoutPolicyHandler(InitializerParams pattern)