Retries, dead letters and duplicates

Three things that are really one thing. A retry means a message arrives again; a message arriving again means a handler runs twice; a handler that runs twice and changes something needs to be able to tell. So the page that configures the first has to answer the third.

This is the one pattern area that is mostly configuration, and the settings table is on the configuration page. This page is what they do.

Off by default

A retry that is on by default is a retry nobody chose. Turning it on is one line:

{
  "acemq": {
    "listener": {
      "retry": {
        "enabled": true,
        "maxAttempts": 5,
        "initialDelay": "00:00:01",
        "maxDelay": "00:05:00",
        "multiplier": 2.0,
        "jitter": 0.2,
        "brokerWaitThreshold": "00:00:05"
      }
    }
  }
}

That is a ladder of 1s, 2s, 4s, 8s — bounded at five minutes, jittered by a fifth, five attempts including the first. maxAttempts counts the first attempt, so five means four retries.

Durations are TimeSpans and therefore written "00:00:01", not 1s. That is the configuration binder's convention rather than a choice made here, and it is the first thing to trip over when copying an application.yaml across from the Spring Boot starter.

Per consumer

builder.Services
    .AddAceMq(builder.Configuration.GetSection("acemq"))
    .AddConsumer<Order, OrderHandler>("orders.new")
    .AddConsumer<Email, EmailHandler>("email.outbound", configure: r =>
        r.Retry = new AceMqRetryOptions
        {
            Enabled = true,
            MaxAttempts = 10,
            InitialDelay = TimeSpan.FromSeconds(5),
            MaxDelay = TimeSpan.FromHours(1),
            BrokerWaitThreshold = TimeSpan.FromSeconds(5),
        });

r.Retry replaces the listener defaults for that consumer rather than merging with them, because it is an object and not a set of nullable fields. A consumer that wants the default ladder with one number changed has to write the ladder out — which is verbose and is at least unambiguous. Anything left at its own default takes AceMqRetryOptions' default, not the value in appsettings.json.

giveUpAfter is the one worth knowing about and is not in the example above. It gives up on a message older than a duration however many attempts are left, which is what you want for anything whose answer stops being useful — a notification nobody needs an hour late.

Where the waiting happens

The library does not sleep in the handler. A retry republishes the message onto a delay queue in the broker, and the broker delivers it back when the delay expires. That is the whole point of the ladder: a waiting message holds nothing — no thread, no prefetch slot, no delivery — and a restart does not have to survive the wait.

Except for the short ones. brokerWaitThreshold, thirty seconds by default, is the line: delays at or above it wait on a rung queue in the broker, and delays below it wait in the process.

That line is a shutdown setting as much as a reliability one, and it is the most consequential number on this page:

Lowering the threshold to five seconds moves nearly everything to the broker and is the cheapest way to make a drain predictable. The full argument is on the lifecycle page.

The queues this declares

AddConsumer declares, per consumer, for you:

{queue}.dlq dead letters
{queue}.parked messages set aside for a person
{queue}.retry.{delay} one rung per broker-side delay in the ladder

Plus the exchanges they hang off, acemq.dlx and acemq.retry. This happens whether or not the queue is in acemq:topology, and it happens with no retry policy too — a consumer with no ladder still needs somewhere to dead-letter, so .dlq and .parked appear regardless. The topology page says the same from the other direction.

They are declared at startup rather than on first failure, deliberately: a queue an operator can see from start-up is one they can alert on before the first failure rather than after it.

acemq.retry.rung.missing is the metric for a rung that was absent so the wait happened in the process instead. It is worth an alert on its own merits and it is also the early warning that the next deployment's drain will be slow. See observability.

What a handler can decide

Ack is the library's, and each answer means something different to the ladder:

Ack.Accept() Done. Acknowledged, and nothing comes back.
Ack.Retry(reason) Through the ladder, at whatever the next rung is.
Ack.Retry(after, reason) Through the ladder, but wait exactly this long.
Ack.DeadLetter(reason) Skip the ladder. This will fail the same way for ever.
Ack.Release() Not now. Back to the broker unchanged, for somebody else.
Ack.Park(reason) Set aside for a person, separately from the dead letters.

Both DeadLetter and Park require a reason, and the requirement is not ceremony: the reason is what a person reading orders.new.parked at nine in the morning has to work from.

Ack.DeadLetter skipping the ladder is the one worth using more than people do. A message whose payload will not deserialise, or that names a customer who does not exist, does not become valid after four exponential delays — it consumes four attempts and arrives at the same place five minutes later. Retrying is for things that might have changed: a timeout, a lock, a rate limit, a dependency restarting.

Throwing

Allowed, and treated as a failure by the library's own rules: retried if a ladder is configured, dead-lettered if not — or requeued if listener:requeueOnFailure is set.

Returning an Ack is better where the handler knows which kind of failure it had, because Ack.DeadLetter("no such customer") says something and an exception type usually does not.

requeueOnFailure

{ "acemq": { "listener": { "requeueOnFailure": true } } }

Puts a failed message straight back on the queue instead of dead-lettering it. It has no delay and no attempt limit, so a message that always fails is a loop that consumes a consumer for as long as the process runs. It exists for the case where the failure is obviously transient and the queue is obviously short; the ladder is the answer the rest of the time.

Which attempt is this

message.Attempt counts from one, and message.IsFirstAttempt is the readable form of Attempt <= 1:

public async Task<Ack> HandleAsync(IMessage<Order> message, CancellationToken ct)
{
    if (!message.IsFirstAttempt)
    {
        _log.LogWarning(
            "order {Id}, attempt {Attempt}", message.Payload.Id, message.Attempt);
    }

    // The age of the message rather than of this attempt, which is what a decision about
    // giving up should be made on.
    if (message.Envelope.Age > TimeSpan.FromHours(1))
    {
        return Ack.DeadLetter("older than an hour");
    }

    return Ack.Accept();
}

The counter travels on the message, not in the process, which is why a restart does not reset it and why a redelivery after a failed drain comes round at the same number. It is also why the raw AMQP header cannot be trusted for this: a broker requeues the bytes it was given, so the wire header still says what it said the first time. message.Attempt is the library's count and is the one to read.

Handling a message once

A retry means a redelivery, and a redelivery means the handler runs again — on a message some of whose work may already have been done. If the handler only reads, that is free. If it charges a card, it is not.

Two answers, and the first is better when it is available.

Make the work idempotent

An INSERT ... ON CONFLICT DO NOTHING keyed on the message id, an update that sets a state rather than incrementing a counter, a payment provider's own idempotency key — none of these need anything from this package, and all of them are correct across instances and restarts, which is more than a store can promise.

Or ask for a store

r.Idempotency on a consumer registration hands the library a store, and the library claims the message before the handler runs:

builder.Services.AddSingleton<IIdempotencyStore>(_ =>
    new InMemoryIdempotencyStore(TimeSpan.FromHours(6)));

builder.Services
    .AddAceMq(builder.Configuration.GetSection("acemq"))
    .AddConsumer<Order, OrderHandler>("orders.new", configure: r =>
        r.Idempotency = sp => sp.GetRequiredService<IIdempotencyStore>());

It is a factory, Func<IServiceProvider, IIdempotencyStore>, and not a store, because AddConsumer runs while the container is still being built — so a store that is a service in it cannot be an argument here. The factory is called once, when that consumer starts. A store you already have is r.Idempotency = _ => store;.

There is no configuration key for this and there will not be one. A store worth having is a connection string, a table name and a retention window at minimum, and naming one in appsettings.json would mean this package opening database connections.

What the store sees

The key is message.Envelope.Id — the library's own message id, which travels with the message and survives a republish. Not the AMQP message id, and not anything the handler chooses.

The sequence is worth knowing because it explains the interface:

  1. ClaimAsync(id) before the handler runs. False means the message is accepted and the handler is never called — silently, because from the application's point of view it was handled, just not now.
  2. The handler runs.
  3. ConfirmAsync(id) when it succeeded, so a later redelivery is refused at step one.
  4. ReleaseAsync(id) when it failed, so the retry is not mistaken for a duplicate. This is the step a hand-rolled deduplication gets wrong, and getting it wrong means the first failure is the last attempt.

Claiming before rather than after is also what stops two consumers handling the same message concurrently, which is a real case at concurrency above one.

The stores in the box

InMemoryIdempotencyStore(retention) — and InMemoryIdempotencyStore.ForOneDay() — is bounded by age and by size, 100_000 entries unless a second argument says otherwise. Read its own documentation before relying on it: it is per process and lost on restart. It deduplicates the redeliveries one running consumer sees, which is most of them, and it cannot deduplicate across instances or across a restart. It also has an Evictions counter, which is the number to watch — an eviction is a claim forgotten early, and a forgotten claim is a duplicate waiting to happen.

DbIdempotencyStore is the one that means it:

builder.Services.AddSingleton<IIdempotencyStore>(sp =>
    new DbIdempotencyStore(
        () => new NpgsqlConnection(connectionString),
        retention: TimeSpan.FromDays(7)));

The first argument is a ConnectionSupplier — delegate DbConnection ConnectionSupplier() — so any ADO.NET provider works, and CreateTableSql() gives you the DDL to put in a migration. The table and parameter prefix are the third and fourth arguments for a database whose placeholders are not @.

The real reason to use it is not durability, it is the transaction. A claim written to the same database as the work, in the same transaction, commits or rolls back with it — which is the only arrangement where "handled once" is actually true rather than nearly true. That needs the handler to own the transaction rather than the library, so it is the hand-written version of this pattern; the registration above is the convenient version, and the difference is worth understanding before choosing.

A duplicate is invisible

By design, and it is worth saying out loud: a refused claim returns Ack.Accept(), so a duplicate produces no log line from this package, no metric of its own and no trace of having been dropped. If you need to know how many are being deduplicated, count them in the store — a ClaimAsync that returns false is the event, and it is your code.

Testing all of it

The in-memory transport redelivers, so a ladder and a store can both be tested with no broker:

["acemq:url"] = "memory://" + nameof(ThisTest),
["acemq:listener:retry:enabled"] = "true",
["acemq:listener:retry:maxAttempts"] = "3",
["acemq:listener:retry:initialDelay"] = "00:00:00.100",

Keep the delays in milliseconds and the test runs in a second. What that cannot prove is that the rung queues exist on a real broker, which is what the integration suite is for — this package has a test named The_retry_ladder_is_declared_when_a_consumer_asks_for_it for exactly that. See testing.