Skip to content

[Docs] RequeueCount: the code counts deliveries, the docs count requeues; a clarification of which is intended #4366

Open

Description

@gitmln

Describe the bug

This is actually not a bug, but discrepancy between the docs and the actual Brighter behavior.
Reading Proactor.RequeueMessage / Reactor.RequeueMessage alongside the docs, the two describe the counter differently, and it is not obvious which one is intended. The pump increments first and then compares with >=:

message.Header.UpdateHandledCount();
if (DiscardRequeuedMessagesEnabled() && message.HandledCountReached(RequeueCount))   // Header.HandledCount >= requeueCount
    return RejectMessage(...);
return Channel.RequeueAsync(message, delay ?? RequeueDelay);

So a message is rejected on the failure whose ordinal equals RequeueCount: with requeueCount: 3 the handler runs 3 times and the message is requeued twice, and requeueCount: 1 rejects on the first DeferMessageAction, the same as requeueCount: 0. This looks deliberate: the core test MessageDispatch/Proactor/When_a_requeue_count_threshold_for_commands_has_been_reached.cs expects exactly that (RequeueCount = 3, two messages, SendCount == 6), and #4341 talks about the same counter as a "delivery budget".

The user-facing text reads as "number of requeues" instead:

  • Subscription.RequeueCount XML doc: "the number of times that we can requeue a message before we abandon it as poison pill"; constructor parameter: "The number of times you want to requeue a message before dropping it".
  • Docs contents/ErrorHandlingOptions.md: "The maximum number of times a message can be requeued ... When the count is exceeded, the message is rejected", "A value of 0 means the message is rejected on the first DeferMessageAction without any requeue", and the worked example "requeue the message up to 3 times. On the 4th failure, the message is rejected and routed to orders.dlq".
  • Docs contents/HandlerFailure.md: "the maximum number of times a message can be requeued before it is treated as a poison message ... When the count is exceeded".
  • The subscription tables on the transport pages (RabbitMQ, SQS, Service Bus, Kafka, Redis, MsSql, PostgreSQL, MQTT, RocketMQ, GCP Pub/Sub, in-memory): "Times a message is requeued before it is treated as a poison pill; -1 is unlimited".

Measured on 10.7.0 with an always-deferring handler on the in-memory transport (test below):

requeueCount handler runs (docs) handler runs (actual) requeues (actual)
0 1 1 0
1 2 1 0
3 4 3 2

Either reading is fine on its own; it would just help if the docs and the code said the same thing, since someone configuring requeueCount: 3 from the docs will expect one more attempt than they get. The fact that 0 and 1 behave identically may also be worth a look. Two possible resolutions, whichever matches the intent:

  1. Code, if "number of requeues" is the intended meaning: HandledCount > RequeueCount (one line in Message.HandledCountReached, or the two pump call sites) with the threshold test updated to SendCount == 8; -1 is unaffected because DiscardRequeuedMessagesEnabled() guards it.
  2. Docs, if "delivery budget" is the intended meaning: say that requeueCount is the total number of deliveries, so N gives N - 1 requeues and 1 behaves like 0, in the XML doc, the two pages and the transport tables.

To Reproduce

xUnit, in-memory transport, Paramore.Brighter 10.7.0, mirrors the core threshold test but counts handler executions:

public sealed class AlwaysDeferHandlerAsync : RequestHandlerAsync<DeferredCommand>
{
    public static int Attempts;

    public override Task<DeferredCommand> HandleAsync(DeferredCommand command, CancellationToken cancellationToken = default)
    {
        Attempts++;
        throw new DeferMessageAction();
    }
}

[Theory]
[InlineData(0)]
[InlineData(1)]
[InlineData(3)]
public async Task When_RequeueCount_Is_N_The_Message_Should_Be_Requeued_N_Times_And_Rejected_On_Failure_N_Plus_1(int requeueCount)
{
    // CommandProcessor with the handler above; ChannelAsync over InMemoryMessageConsumer(routingKey, bus, TimeProvider.System, ackTimeout: 30 s)
    var pump = new Proactor(commandProcessor, _ => typeof(DeferredCommand), mapperRegistry, new EmptyMessageTransformerFactoryAsync(),
        new InMemoryRequestContextFactory(), channel)
    {
        Channel = channel, TimeOut = TimeSpan.FromMilliseconds(500), RequeueCount = requeueCount, RequeueDelay = TimeSpan.Zero,
    };

    bus.Enqueue(/* one MT_COMMAND message */);
    var run = Task.Factory.StartNew(() => pump.Run(), TaskCreationOptions.LongRunning);
    await Task.Delay(TimeSpan.FromSeconds(3));
    channel.Enqueue(/* MT_QUIT */);
    await run;

    Assert.Equal(requeueCount + 1, AlwaysDeferHandlerAsync.Attempts);
}

Result:

requeueCount: 0  passed
requeueCount: 1  Expected: 2  Actual: 1
requeueCount: 3  Expected: 4  Actual: 3

Exceptions (if any)

None.

Further technical details


Analysis and the repro were prepared with help of an AI assistant.

Activity

  1. iancooper commented on Sep 26, 2026

    @iancooper
    Member

    This is a docs issue, as we are essentially thinking about OnceOnly with 1 and 0 as a default of NoRetries, which are semantically equivalent

  2. changed the title [-]RequeueCount: the code counts deliveries, the docs count requeues; a clarification of which is intended[/-] [+][Docs] RequeueCount: the code counts deliveries, the docs count requeues; a clarification of which is intended[/+] on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions