Skip to content

Commit 6b215a4

Browse files
authored
fix: resolve retry policies per consumer, re-dispatch only failed handlers on retry, and reject unroutable queue exchange types (#325)
1 parent 0e136d6 commit 6b215a4

22 files changed

Lines changed: 1450 additions & 171 deletions

docs/articles/messaging.md

Lines changed: 62 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -411,8 +411,18 @@ Notes and trade-offs:
411411

412412
## Retries
413413

414-
`q.UseRetry(...)` configures the retry policy for a queue's consumers. There are two
415-
execution modes:
414+
Retry policies are resolved **per consumer**. A consumer's effective policy is its own
415+
`AddConsumer<TConsumer>(c => c.UseRetry(...))` when configured, otherwise the queue-level
416+
`q.UseRetry(...)` default; a consumer with neither fails terminally on its first failure. The
417+
resolution also applies to polymorphic registrations — a policy configured on
418+
`IConsumer<IOrderEvent>` governs deliveries of every subscribed concrete implementer.
419+
420+
Request consumers never retry: a thrown exception is immediately returned to the requester as an
421+
RPC fault reply (see [Request/Reply](#requestreply)), so `UseRetry` inside
422+
`AddRequestConsumer(...)` throws at configuration time, and a queue-level default does not apply
423+
to them. Retry a failed request on the requesting side if needed.
424+
425+
There are two execution modes:
416426

417427
- **Delayed re-delivery (default):** a failed message is re-published to the queue's retry
418428
exchange with a delay and re-delivered later. Good for back-off without holding a consumer,
@@ -421,8 +431,39 @@ execution modes:
421431
order. Opt in with `q.UseRetry(r => { r.Immediate(3); r.InMemory(); })`. Partitioned queues
422432
use in-memory retry **automatically**, since ordering requires it.
423433

434+
### Several consumers on one delivery
435+
436+
When a queue runs several consumers for the same message, one consumer's failure neither skips
437+
the others in that attempt nor re-runs the ones that already succeeded:
438+
439+
- Every pending consumer runs once per attempt; failures are collected per consumer.
440+
- On retry, **only the consumers that failed are re-dispatched** — on the delayed path the
441+
re-publish stamps the failed consumers' identities into an `x-retry-handlers` header and the
442+
re-delivery dispatches just those (an identity that no longer matches — the consumer was renamed
443+
or removed mid-retry — is logged and skipped).
444+
- Retries advance in shared rounds counted by `x-retry-count`, and each consumer's budget and
445+
back-off come from **its own** policy. Consumers retrying in the same round share the actual
446+
wait: the longest delay any of their policies requests, so no consumer retries earlier than its
447+
own back-off asks (it may retry later).
448+
- The delivery is held in-process (in-memory mode) when the queue is partitioned or any retrying
449+
consumer's policy is in-memory; otherwise it goes through the retry queue.
450+
- A consumer that exhausts its retries (or has no policy, or threw an ignored exception) fails
451+
terminally: its `Fault<T>` is published immediately, while the delivery stays live for any
452+
consumers still retrying.
453+
424454
On exhaustion the message is dead-lettered (when a dead-letter queue is configured) in both
425-
modes.
455+
modes — precisely, the delivery is nacked for dead-lettering when its **final** retry round ends
456+
with a terminal failure. A consumer that failed terminally in an earlier round (while others kept
457+
retrying and eventually succeeded) is reported through its published fault, not through the
458+
dead-letter queue.
459+
460+
### Ignored exceptions
461+
462+
`r.Ignore<TException>()` (or the `IgnoreExceptions` list in configuration) exempts exception
463+
types from retrying — a matching failure is immediately terminal. The ignore list is resolved
464+
once, on first use; config-supplied names outside the core library must be **assembly-qualified**
465+
(`"Acme.Orders.PricingException, Acme.Orders"`), and a name that cannot be resolved is skipped
466+
with a startup warning — the exceptions it was meant to exclude are retried.
426467

427468
> **Head-of-line caveat (delayed re-delivery):** the delayed retry uses one shared retry queue with a
428469
> per-message TTL, and RabbitMQ expires messages only from the *head* of a queue. With variable or jittered
@@ -432,9 +473,11 @@ modes.
432473
433474
## Faults
434475

435-
When a consumed message fails terminally — every retry exhausted — a `Fault<T>` is published
436-
**by convention** to a shared topic exchange (default `Fault.Exchange`, configurable via
437-
`Messaging:Options:FaultExchangeName`). No per-message opt-in by the producer is required, so faults
476+
When a consumer fails terminally — its retries exhausted, or it has no retry policy — a `Fault<T>`
477+
is published **by convention** to a shared topic exchange (default `Fault.Exchange`, configurable via
478+
`Messaging:Options:FaultExchangeName`). Faults are per consumer: one fault is published for each
479+
terminally-failed consumer, so a delivery dispatched to several consumers can produce several faults,
480+
each carrying that consumer's exception. No per-message opt-in by the producer is required, so faults
438481
are observable broker-side without changing any producer. The faulted message's URN is the routing
439482
key, so an operator binds a queue to the fault exchange — `#` for every fault, or a specific URN to
440483
filter by faulted message type — and reads the payload. The fault body is a `Fault<T>` JSON document
@@ -466,7 +509,8 @@ any AMQP consumer bound to the exchange — rather than a typed `IConsumer<Fault
466509

467510
A message can override the routing per-message: if it carries an explicit `FaultAddress`, the fault is
468511
routed **point-to-point** to that address (through the broker's default exchange) instead of being
469-
broadcast to the fault exchange — exactly one fault is emitted either way. Set it on publish:
512+
broadcast to the fault exchange — exactly one fault per terminally-failed consumer is emitted either
513+
way. Set it on publish:
470514

471515
```csharp
472516
await publisher.PublishAsync(new OrderCreatedEvent(orderId), ctx =>
@@ -493,6 +537,13 @@ binding `order.*` matching producer keys like `order.created`). When the binding
493537
the broker receives an empty pattern: fanout/headers exchanges ignore it; direct/topic exchanges only
494538
match an empty published key — supply a specific pattern explicitly when needed.
495539

540+
Exchange-type choice belongs to the **message** exchange (`MessageConfiguration<TMessage>.ExchangeType`),
541+
which is where `Subscribe` binding patterns filter. The queue's **own** exchange
542+
(`QueueDefinition.ExchangeType`) must stay `Fanout`: the queue is bound to it with an empty routing key,
543+
and retry re-deliveries dead-letter back into it carrying the original routing key, so a Direct, Topic,
544+
or Headers queue exchange would silently drop both normal and retry deliveries. The RabbitMQ transport
545+
rejects a non-fanout queue exchange when the host starts.
546+
496547
```csharp
497548
// Producer side: what the publisher writes on the wire.
498549
messaging.ConfigureMessage<OrderCreatedEvent>(message =>
@@ -826,6 +877,10 @@ The reply is a normal `MessageEnvelope` (single-serialized, like every other mes
826877
type and message); the requester maps it to a `Result<TResponse>` failure with the
827878
`Messaging.Request.Failure` error code.
828879

880+
A request consumer runs exactly once per request: a thrown exception becomes the fault reply
881+
rather than entering the retry machinery, so retry policies do not apply to request consumers
882+
(see [Retries](#retries)) — retry on the requesting side when a request should be re-attempted.
883+
829884
## Writing a Custom Transport
830885

831886
`Vulthil.Messaging` is also a *build-your-own-transport* SDK. The transport-agnostic

src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandler.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,28 @@ namespace Vulthil.Messaging.RabbitMq.Consumers;
1212
/// </summary>
1313
internal sealed record MessageHandler
1414
{
15-
/// <summary>The retry policy applied by the worker when this handler's plan throws.</summary>
15+
/// <summary>
16+
/// The effective retry policy the worker applies when this handler throws — the registration's own policy
17+
/// when set, otherwise the queue's default, resolved once by the registry; <see langword="null"/> means the
18+
/// handler fails terminally on its first failure. Always <see langword="null"/> for request consumers,
19+
/// which reply with an RPC fault instead of retrying.
20+
/// </summary>
1621
public RetryPolicyDefinition? RetryPolicy { get; init; }
1722

23+
/// <summary>
24+
/// Stable identity of this handler: the consumer's CLR full name paired with the registered message type's
25+
/// full name. Stamped on delayed-retry re-publishes so the re-delivery re-dispatches only the handlers that
26+
/// failed; stable across processes for as long as the consumer type keeps its name.
27+
/// </summary>
28+
public required string Identity { get; init; }
29+
1830
/// <summary>The consumer contract the handler implements.</summary>
1931
public required HandlerKind Kind { get; init; }
2032

33+
/// <summary>Builds the stable handler identity for a consumer/registered-message type pair.</summary>
34+
internal static string BuildIdentity(Type consumerType, Type messageType)
35+
=> $"{consumerType.FullName}:{messageType.FullName}";
36+
2137
/// <summary>
2238
/// Dispatches a deserialized message through the consume pipeline and (for RPC) publishes the response through
2339
/// the supplied <see cref="GatedPublisher"/>, which serializes the write with the worker's other channel

src/Vulthil.Messaging.RabbitMq/Consumers/MessageHandlerFactory.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public static MessageHandler ForConsumer<TConsumer, TMessage>(RetryPolicyDefinit
2525
=> new()
2626
{
2727
RetryPolicy = retryPolicy,
28+
Identity = MessageHandler.BuildIdentity(typeof(TConsumer), typeof(TMessage)),
2829
Kind = HandlerKind.Consumer,
2930
DispatchAsync = async (sp, message, ea, envelope, _, ct) =>
3031
{
@@ -53,6 +54,7 @@ public static MessageHandler ForRequestConsumer<TConsumer, TRequest, TResponse>(
5354
=> new()
5455
{
5556
RetryPolicy = retryPolicy,
57+
Identity = MessageHandler.BuildIdentity(typeof(TConsumer), typeof(TRequest)),
5658
Kind = HandlerKind.RequestConsumer,
5759
DispatchAsync = async (sp, message, ea, envelope, publishAsync, ct) =>
5860
{

0 commit comments

Comments
 (0)