@@ -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+
424454On 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
438481are observable broker-side without changing any producer. The faulted message's URN is the routing
439482key, so an operator binds a queue to the fault exchange — ` # ` for every fault, or a specific URN to
440483filter 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
467510A message can override the routing per-message: if it carries an explicit ` FaultAddress ` , the fault is
468511routed ** 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
472516await publisher .PublishAsync (new OrderCreatedEvent (orderId ), ctx =>
@@ -493,6 +537,13 @@ binding `order.*` matching producer keys like `order.created`). When the binding
493537the broker receives an empty pattern: fanout/headers exchanges ignore it; direct/topic exchanges only
494538match 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.
498549messaging .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
0 commit comments