Retries, dead letters and replay
What happens to a message a handler cannot handle, and how it gets another chance.
Four things, in the order a service usually needs them:
- Retries — try again, later, without blocking the consumer. Properties.
- Dead letters — stop trying, and keep the message somewhere it can be looked at. One property.
- Replay — send the kept messages back through once the cause is fixed. A bean.
- Idempotency — handle a redelivery without doing the work twice. A bean.
Retries
The ladder is properties, and Listeners has the
arithmetic for why it is not a Thread.sleep:
acemq:
listener:
retry:
enabled: true
max-attempts: 5
initial-delay: 2s
max-delay: 30s
multiplier: 2.0
jitter: 0.2
give-up-after: 10mmax-attempts counts deliveries, the first one included,
so five attempts means four waits: 2s, 4s, 8s, 16s — each spread by up
to twenty per cent, and none of them reaching the 30s ceiling, which
only starts biting at seven attempts. The message is abandoned outright
once it is ten minutes old however many attempts are left.
give-up-after is the one people leave off and then want:
without it, a message published during an outage keeps its full ladder
and is still being retried long after the batch it belonged to is
irrelevant.
Every @AceListener in the application shares this
ladder. It is connection-level configuration, not per-listener — see the limits for what to do
when one queue genuinely needs a different one.
Two exceptions the ladder treats differently
@AceListener(queue = "payments.new")
void onPayment(Payment payment) {
if (payment.amount() < 0) {
throw new AceFatalException("a payment cannot be negative: " + payment.amount());
}
gateway.take(payment); // throws something transient; the ladder handles it
}AceFatalException means do not retry this — the
message is wrong, and a fifth attempt will be wrong in the same way. It
goes straight to the dead-letter queue on the first attempt instead of
occupying the ladder for ten minutes first.
AceRetryableException is the opposite statement, made
explicitly. Any other exception is retried too, so it is only worth
throwing when the intent needs to be visible in the code.
The distinction is the one thing worth taking from this page into a handler. A validation failure and an unreachable gateway are both "the handler threw", and treating them the same means either the invalid message is retried five times or the gateway is given one attempt.
The delays are visible
RetryPolicy policy = RetryPolicy.exponential(5, Duration.ofSeconds(2), Duration.ofSeconds(30));
policy.schedule(); // [PT2S, PT4S, PT8S, PT16S] — one per retry, so maxAttempts - 1
policy.brokerRungs(); // the delays long enough to wait in the broker rather than in memoryUseful in a test, and useful for answering "how long before this
message is dead-lettered" without reading the implementation.
RetryPolicy.fixed(3, Duration.ofSeconds(1)) and
RetryPolicy.none() are the other two constructors.
Doubling and twenty per cent jitter are a cross-language default rather than a taste: the same policy has to produce the same numbers in Go, .NET, Python and Ruby, because the same message can be retried by a consumer written in any of them. A message that waited one second under one library and five under another has no schedule at all.
brokerRungs() is worth knowing about if you ever look at
the broker. A delay short enough to hold in memory is held there; a
longer one — waitInBrokerFrom(...), thirty seconds by
default — is spent in a rung queue on the broker instead, so a consumer
restart does not lose it and a ten-minute backoff does not pin a
thread.
Dead letters
A message that cannot be handled has to go somewhere, and there are two quite different routes to it. Getting this wrong is the single most common way a message is lost quietly, so it is worth separating them.
The library's route. A consumer started with a retry
ladder declares what the ladder needs before it consumes anything: the
rung queues, a retry exchange, acemq.dlx, and
<queue>.dlq and <queue>.parked
bound to it. When the ladder gives up, the consumer publishes the
message to <queue>.dlq itself. Nothing needs
declaring for that to work — turning on
acemq.listener.retry.enabled is enough.
The broker's route. A message the library never gets
to see — one that expires against an x-message-ttl, or one
a handler rejects with no ladder configured at all — is dead-lettered by
the broker, and only if the queue was declared with
x-dead-letter-exchange. A consumer deliberately does not
add that argument: it does not own the source queue, does not know
whether it was meant to be quorum or classic, and a redeclaration that
guesses wrong is a PRECONDITION_FAILED that stops the
consumer starting at all.
So the argument has to come from wherever the queue is declared,
which for this starter means application.yml:
acemq:
topology:
exchanges:
- { name: payments, type: topic }
queues:
- { name: payments.new, dead-letter: true }
bindings:
- { queue: payments.new, exchange: payments, routing-key: payment.* }That one line declares four things, because they are only correct together:
payments.new |
with x-dead-letter-exchange: acemq.dlx and
x-dead-letter-routing-key: payments.new.dlq |
acemq.dlx |
a direct exchange, shared by every dead-lettering queue in the topology |
payments.new.dlq |
a message that was understood and could not be processed |
payments.new.parked |
a message that could not even be decoded |
A queue pointed at a dead-letter exchange nothing declares, or at one with no queue bound to it, throws messages away exactly as if dead-lettering had never been configured — which is why this is one property rather than four blocks of YAML.
The names are the same ones the ladder uses, so the two routes
converge: a give-up and an expiry both end up in
payments.new.dlq, and one replay drains both. That is not a
coincidence — .dlq and .parked are the
cross-language convention, and the Go, .NET, Python and Ruby libraries
name them identically.
.dlq and .parked are separate on
purpose. A payment that failed because the gateway returned 500
and a payment whose JSON does not parse are investigated differently and
fixed differently. Mixed into one queue, whoever drains it sorts them by
hand.
dead-letter: true works on a classic queue too, and
keeps the arguments you give it:
queues:
- { name: orders.audit, type: classic, dead-letter: true, arguments: { x-message-ttl: 604800000 } }Setting x-dead-letter-exchange yourself in
arguments alongside dead-letter: true is
refused rather than overruled. Either answer would be a guess about
which of two conflicting instructions was meant.
Seeing what is in there
@Service
class DeadLetters {
private final AceMq mq;
DeadLetters(AceMq mq) {
this.mq = mq;
}
long waiting() {
return mq.messageCount("payments.new.dlq");
}
long undecodable() {
return mq.messageCount("payments.new.parked");
}
}Worth a gauge. A dead-letter queue that is normally empty and is now not is the earliest signal that something downstream changed, and it is a number nobody looks at unless it is on a dashboard.
@Bean
MeterBinder deadLetterDepth(AceMq mq) {
return registry -> Gauge.builder("orders.dlq.depth", () -> mq.messageCount("payments.new.dlq"))
.description("messages the payments handler gave up on")
.register(registry);
}Replay
Once the cause is fixed, the dead letters need to go back.
mq.replay(queue) moves them from the dead-letter queue to
the queue they came from.
@Service
class PaymentReplay {
private final AceMq mq;
PaymentReplay(AceMq mq) {
this.mq = mq;
}
/** How many are waiting, and where they would go. */
long pending() {
return mq.replay("payments.new").pending();
}
/** Everything. */
int replayAll() {
return mq.replay("payments.new").replayAll();
}
/** A hundred at a time, which is how a large backlog is done safely. */
int replaySome() {
return mq.replay("payments.new").replay(100);
}
/** Only the ones a fix actually covers. */
int replayGatewayFailures() {
return mq.replay("payments.new")
.replay(500, delivery -> delivery.headers()
.getOrDefault(AceHeaders.ERROR, "")
.toString()
.contains("gateway"));
}
}mq.replay(q) reads from q.dlq and publishes
back to q; from() and to() say
so, which is worth asserting in a test rather than trusting.
.parked() switches the source to q.parked
instead, for the messages that could not be decoded — after the codec is
fixed rather than after the downstream is.
A replayed message arrives with its attempt counter
reset, so it gets the full ladder again. That is the
right default: the reason it is being replayed is that the previous
attempts were against a broken downstream and tell you nothing.
.keepingAttempts() is there for the case where they do — a
poison message being given exactly one more chance.
The envelope records the replay: replayedFrom(),
replayedAt() and replayCount(). A handler can
see that it is looking at a second pass, and a message with a replay
count of four has been through this loop four times and is not going to
succeed on the fifth.
Expose this deliberately. A replay endpoint is a
button that republishes production traffic, and replayAll()
on a queue with 400,000 messages in it is a self-inflicted incident. An
actuator endpoint behind an authenticated role, or a scheduled job with
a bound, rather than an open POST /replay.
Idempotency
A message can be delivered twice. Not often, but by design: a redelivery after a consumer died mid-handler, a retry whose original did in fact succeed, a replay somebody ran twice. Any handler with a side effect needs to survive it.
An IdempotencyStore remembers the message ids it has
completed and drops the duplicates before the handler sees them.
@Configuration
class Idempotency {
/** Shared by every instance, which is the point — two pods are two consumers. */
@Bean
IdempotencyStore idempotencyStore(DataSource dataSource) {
JdbcIdempotencyStore store = new JdbcIdempotencyStore(dataSource);
store.createSchemaIfAbsent();
return store;
}
}new JdbcIdempotencyStore(dataSource, retention, claimTimeout, table)
is the constructor with the knobs: how long a completed id is
remembered, how long a claim is held before it is assumed abandoned, and
which table.
InMemoryIdempotencyStore.forOneDay() is the other
implementation. It is correct for exactly one process, so it is right
for a test and for a single-instance job, and wrong for anything that
scales out — two replicas with in-memory stores deduplicate
independently, which is to say not at all.
What the annotation cannot reach
@AceListener does not take an idempotency store, and
neither acemq.listener.* nor the annotation has a property
for one. The store is applied through
ConsumerOptions.idempotent(...), and the registry that
starts annotated listeners builds those options from properties
alone.
So a consumer that needs one is declared as a bean instead of as an annotation:
@Configuration
class PaymentConsumers {
@Bean(destroyMethod = "close")
ConsumerGroup payments(AceMq mq, IdempotencyStore store, PaymentHandler handler) {
return mq.consumeGroup("payments.new", Payment.class, handler::handle)
.concurrency(4)
.prefetch(40)
.options(ConsumerOptions.prefetch(40)
.idempotent(store)
.withRetry(RetryPolicy.exponential(
5, Duration.ofSeconds(2), Duration.ofSeconds(30))))
.start();
}
}This is the general escape hatch, and it is worth being clear that it
is not a workaround. @AceListener covers the common case
from properties; a bean covers everything, because it is the library's
own API with nothing in the way. The costs of the bean form are real but
small: it is not in AceListenerRegistry, so
registry.get(id) will not find it and the registry's
ordered drain does not cover it — destroyMethod = "close"
closes it when the context is destroyed, which is after the annotated
listeners have drained.
The same applies to a per-listener retry ladder, a per-listener codec
(ConsumerOptions.as(codec)), and
requeueOnFailure for one queue but not the others. All
three are ConsumerOptions, and all three mean declaring
that consumer as a bean.
handler::handle above is a
MessageHandler<Payment>, which takes
Message<Payment> and returns nothing.
AckAwareHandler, which returns an Ack and so
can dead-letter or delay a single message by hand, is not reachable from
consumeGroup either — it is an interface the library
exposes and does not yet dispatch to.
Graceful shutdown
A message inside a handler when the process exits is redelivered, and a rolling deployment that does not wait produces a burst of duplicates that nobody attributes to the deployment.
acemq:
listener:
shutdown-timeout: 45sAceListenerRegistry is a SmartLifecycle at
the default phase, so it stops first: every listener drains, then every
listener closes, then — after every lifecycle has stopped — the
AceMq bean is destroyed and the connection closes. Draining
means waiting for the messages already inside handlers; the backlog on
the queue stays on the queue.
Set the timeout above the longest a handler can take, and set your
orchestrator's terminationGracePeriodSeconds above that. A
45-second drain inside a 30-second grace period is a SIGKILL partway
through the drain, which is the thing the drain existed to prevent.
Nothing needs to be called by hand.
mq.drainConsumers(timeout) exists for a consumer declared
as a bean outside the registry, and for a test that wants to assert the
drain.