2. Surviving failure
20 minutes. Needs Docker.
Everything so far assumed the handler works. This is about when it does not.
docker run -d --rm --name rabbit -p 5672:5672 -p 15672:15672 rabbitmq:4-management(For TLS instead of plaintext, the acemq-certs command
generates everything a broker needs — see security.)
Declare somewhere for failures to go
import (
acemq "github.com/AceMQ-Company/acemq-go-amqp/amqp"
_ "github.com/AceMQ-Company/acemq-go-amqp/rabbitmq"
)
mq, err := acemq.Connect(ctx, "amqp://guest:guest@localhost:5672/")
if err != nil {
log.Fatal(err)
}
defer mq.Close()
err = acemq.NewTopology().
Queue("orders.placed").
DeadLetters("orders.placed").
Apply(ctx, mq)
if err != nil {
log.Fatal(err)
}DeadLetters declares four things, and they are only
correct together: the shared acemq.dlx exchange,
orders.placed.dlq and orders.placed.parked
bound to it on their own names, and the
x-dead-letter-exchange and
x-dead-letter-routing-key arguments on
orders.placed itself pointing at it.
Wire them by hand and forget one, and a rejected message is discarded silently: the broker nacks it, finds no dead-letter exchange, and drops it. Nothing reports that.
Look at http://localhost:15672/#/queues — three queues where you declared one.
Two queues, because there are two problems.
orders.placed.dlq is where a message goes when it was tried
and failed; orders.placed.parked is where one goes when
nothing could read it — a body that would not decode, or a handler that
said so. One is usually the world and the other is usually a producer,
and whoever drains them should not have to sort the two apart by
hand.
Fail on purpose
sub, err := acemq.Consume(ctx, mq, "orders.placed",
func(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
log.Printf("attempt %d for %s", m.Envelope.Attempt, m.Payload.OrderID)
return acemq.Retry(errors.New("the pricing service is down"))
})
if err != nil {
log.Fatal(err)
}
defer sub.Close()
pub := acemq.NewPublisher[OrderPlaced](mq, "", "orders.placed")
if err := pub.Send(ctx, OrderPlaced{OrderID: "A-1", TotalCents: 4250}); err != nil {
log.Fatal(err)
}
time.Sleep(30 * time.Second)attempt 1 for A-1
attempt 2 for A-1
attempt 3 for A-1
...
Forever, and as fast as the broker can manage it. With no policy configured a message asking to be retried is sent straight back with nothing to stop it. That consumer is now occupied by one message that will never succeed.
Notice that attempt climbs. It comes off the message
rather than out of a map in this process, because a retry here
republishes the message with the counter advanced
rather than requeueing it — a requeue hands the broker back the bytes it
already had, so a counter kept in the header would never move, and one
kept in memory would be empty again after the restart that the failing
service is about to have.
Bound it
mq, err := acemq.Connect(ctx, "amqp://guest:guest@localhost:5672/",
acemq.WithRetry(acemq.ExponentialRetry(3, time.Second, 10*time.Second)))attempt 1 for A-1
attempt 2 for A-1
attempt 3 for A-1
Then it stops, and the message is in orders.placed.dlq
with the reason on it. Check the management interface: one message.
n, err := mq.MessageCount(ctx, "orders.placed.dlq")
log.Printf("%d dead letters", n)The policy belongs to the connection, so every consumer on it gets
the same one. acemq.RetryWith(policy) overrides it for a
single consumer, which is what to do for the one queue whose work is
worth ten attempts.
Jitter is on by default. The waits are not exactly 1s and 2s; they are spread by ±20%. Without that, every consumer that failed at the same moment retries at the same moment, and a dependency that was struggling gets the whole herd at once.
Ask a policy what it will do before trusting it:
policy := acemq.ExponentialRetry(5, time.Second, time.Minute)
log.Println(policy)
// RetryPolicy[attempts=5, initial=1s, x2, max=1m0s, jitter=0.2, broker=30s]
log.Println(policy.Schedule())
// [1s 2s 4s 8s]Schedule is the waits it will actually use, jitter
aside, and it is four entries for five attempts because the last attempt
is not followed by a wait.
Where the waiting happens
A short wait is spent in this process, holding one of the consumer's
prefetch slots. A long one is not: past a threshold the message goes
onto a rung queue —
orders.placed.retry.30s and friends — with an
x-message-ttl and a dead-letter target pointing back at the
source, so the broker holds it and returns it when the time is up. Ten
minutes of waiting in a Go process is ten minutes of a prefetch slot
doing nothing; ten minutes on a rung costs nothing at all.
The rungs have to exist:
policy := acemq.ExponentialRetry(8, time.Second, 10*time.Minute)
err := acemq.NewTopology().
Queue("orders.placed").
DeadLetters("orders.placed").
Retries("orders.placed", policy).
Apply(ctx, mq)A consumer whose rungs are missing still retries — it waits in the
process instead — and counts acemq.retry.rung.missing each
time, with the rung it wanted as a label. That metric above zero is a
topology call somebody has not made. More in retries and
redelivery.
Say what you mean instead of always retrying
An error on its own cannot tell "try again shortly" from "this will never work". The return value can, and this is the reason it is a return value:
func handle(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
switch quote, err := price(ctx, m.Payload); {
case err == nil:
_ = quote
return acemq.Accept()
case errors.Is(err, errNoSuchItem):
return acemq.Reject(err) // never works; keep the evidence
case errors.Is(err, errUnreachable):
return acemq.Retry(err) // the world; try again
default:
return acemq.Park(err) // nobody could read it
}
}Retrying the second case for ever is how one bad message becomes an outage.
There is a third way to say it, for a failure deep inside the work
where returning the right Ack would mean threading a
decision back up through five functions:
return acemq.Retry(acemq.Fatalf("no such item %q", m.Payload.OrderID))acemq.Fatal marks the reason rather than the request,
and the engine honours the mark over the request: the message is
dead-lettered rather than retried. That is the point of having it — a
library function three layers down can say "this will not improve"
without knowing anything about messaging.
Fill the dead-letter queue, then look at it
orders := make([]OrderPlaced, 20)
for i := range orders {
orders[i] = OrderPlaced{OrderID: fmt.Sprintf("A-%d", i), TotalCents: int64(1000 + i)}
}
results, err := pub.SendAll(ctx, orders)
if err != nil {
log.Fatal(err)
}
log.Printf("published %d", len(results))SendAll rather than a loop: every message goes out
before any confirm is awaited, instead of a full broker round trip each.
Wait for the retries to run out, and all twenty are in
orders.placed.dlq.
Read one without starting a consumer:
pulled, payload, found, err := acemq.PullInto[OrderPlaced](ctx, mq, "orders.placed.dlq")
if err != nil {
log.Fatal(err)
}
if found {
log.Printf("%s failed: %s", payload.OrderID, pulled.Envelope.Error)
if err := pulled.Ack(); err != nil {
log.Fatal(err)
}
}A-0 failed: exhausted 3 attempts: the pricing service is down
The reason is a field on the envelope rather than a header you have
to know the name of. Pull is the right shape for a tool
that drains a queue and the wrong one for ordinary work — it costs a
round trip per message whether one is there or not.
Fix the cause, replay the messages
The pricing service is back. The messages are still in the dead-letter queue:
import "github.com/AceMQ-Company/acemq-go-amqp/patterns"
result, err := patterns.Replay(ctx, mq, patterns.ReplayFrom{
Queue: "orders.placed.dlq",
Limit: 500,
})
if err != nil {
log.Fatal(err)
}
log.Println(result) // moved 20, skipped 0 (drained)With no Exchange set they go back through the default
exchange under their own routing key, which is
orders.placed — the queue the dead-letter queue is named
after. Each one is stamped acemq-replayed-from,
acemq-replayed-at and acemq-replay-count,
which reach your handler on m.Envelope.Headers because they
are deliberately outside the reserved x-acemq- namespace
that the engine strips on the way in.
A replayed message goes back on attempt one with its
dead-letter reason cleared, unless KeepAttempts says
otherwise. It has to: a message dead-lettered on the last attempt of a
three-attempt policy would otherwise be dead-lettered again before any
handler saw it, and the operator who has just fixed the bug would have
moved two thousand messages from one queue to the same queue.
Replay only some of them:
result, err := patterns.Replay(ctx, mq, patterns.ReplayFrom{
Queue: "orders.placed.dlq",
Limit: 100,
Filter: func(env acemq.Envelope, _ []byte) bool {
return strings.Contains(env.Error, "pricing")
},
})What the filter rejects goes back, not away. Losing the rest as a side effect of looking at them would be a poor trade.
Set a Limit. Without one, a replay
against a queue somebody is still writing to may not stop.
result.Reason says which of drained,
limit, deadline or cancelled
ended it, and "moved 500" means something quite different when the limit
was 500.
Shut down without losing work
Stop the process mid-handler and the message comes back — but any side effect it already applied has happened twice by the time it does.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Println("draining")
if err := mq.Close(); err != nil {
log.Printf("close: %v", err)
}
log.Println("stopped")Close stops every consumer, waits for the handlers
already running to return, settles their messages, and only then
releases the connection. That order is the whole point: a settlement
travels on the channel its delivery arrived on, so releasing the
connection first would acknowledge every in-flight message into a
channel that has gone. The broker would hear nothing, hand the messages
to somebody else, and one that had already been republished for its next
attempt would now be on the queue twice.
There is no timeout on it, and no
Drain(30 * time.Second) that gives up. A handler that never
returns holds Close open for as long as it runs, so
give the process long enough to drain — a container
killed nine seconds into a ten-second handler leaves that message to be
redone by somebody else. Bound the work rather than the shutdown:
handler := patterns.Chain(handle,
patterns.WithTimeout[OrderPlaced](10*time.Second))What you have
Bounded retries that do not block a consumer, dead-letter queues that exist, a way to put messages back, and a clean shutdown. Next: never processing twice.