Consuming

sub, err := acemq.Consume(ctx, mq, "orders",
    func(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
        if err := place(ctx, m.Payload); err != nil {
            return acemq.Retry(err)
        }
        return acemq.Accept()
    })
if err != nil {
    return err
}
defer sub.Close()

Consume takes the connection rather than being a method on it, for the same reason NewPublisher does: a method cannot have its own type parameter before Go 1.27.

The decision is the return value

acemq.Accept() it worked. The message is acknowledged and gone.
acemq.Retry(err) try again, if the policy allows another attempt
acemq.Reject(err) never try again. Dead-letter it.
acemq.Park(err) nobody could read it. Send it to {queue}.parked.

Returning the decision rather than calling a method means a handler that forgets to decide does not compile. That matters more than it sounds: a message nobody acknowledges is not lost and is not delivered either — it sits unacknowledged until the connection drops, and then comes back, usually to the same handler, with the same outcome.

Retry or reject?

Retry is for the world being temporarily unhelpful: a database that is down, a service returning 503, a lock somebody else holds. Reject is for the message being the problem: a field that cannot be missing is missing, a reference points at nothing. Retrying that one produces the identical failure five more times and delays the dead-lettering that was always going to happen.

When you are inside a call and cannot see which it is, mark the error instead:

func place(ctx context.Context, o OrderPlaced) error {
    if o.CustomerID == "" {
        return acemq.Fatal(errors.New("no customer"))
    }
    return db.Insert(ctx, o)
}

A Retry carrying a reason marked with Fatal is dead-lettered immediately. The mark wins over the request, which is the point of having it. Fatal looks through wrapping, so an error marked deep inside a call still reads as fatal where the engine asks.

Reject or park?

Both are final and neither is retried. What differs is where the message ends up and therefore who has to look at it.

{queue}.dlq is a queue of work that failed, and somebody drains it looking for a broker or a downstream that has since recovered. {queue}.parked is a queue of messages nobody could read, and somebody drains it looking for the producer that sent them. Mixing the two makes both drains guesswork.

The engine parks a body the codec would not decode by itself, before any handler runs. The exception is a decode error the codec marks with acemq.Retryable. That mark means the failure was not the body's: a claim-check store that timed out, for example. Such a message goes through the retry policy like a handler's acemq.Retry, attempt counter and dead-lettering included. acemq.Park is the same destination and the same reporting — parked on the counter and on the span — for a handler that decoded the message fine and only then found it unreadable:

func handle(ctx context.Context, m acemq.Message[OrderPlaced]) acemq.Ack {
    if m.Envelope.Version > supported {
        return acemq.Park(fmt.Errorf(
            "schema version %d, and this service understands %d",
            m.Envelope.Version, supported))
    }
    ...
}

.NET has had Ack.Park since its first release, and Python's park() and Ruby's Ack.park landed in the same round as this one. All five libraries now let a handler ask for it.

What the handler receives

type Message[T any] struct {
    Payload     T
    Envelope    Envelope
    RoutingKey  string
    ContentType string
    Redelivered bool
    Body        []byte
}

Envelope.Attempt is the count for this delivery, worked out by the engine; the rest came off the wire. Body is there for a handler that wants the undecoded bytes. See the envelope.

Concurrency

One message at a time by default, which keeps a queue's messages in order:

sub, err := acemq.Consume(ctx, mq, "orders", handler,
    acemq.Concurrency(8))

Raising it gives up that order for throughput, and is the right trade for handlers that spend their time waiting on something else. It is the wrong trade where later messages about the same entity must not overtake earlier ones.

Prefetch

How many unacknowledged messages the broker will hand over — twenty by default:

acemq.Prefetch(100)

Too low and the consumer waits on the network between messages. Too high and one consumer hoards a queue while its neighbours idle, and a restart returns a large batch at once. Note that a delayed retry holds its slot while it waits.

Naming the consumer

acemq.ConsumerTag("orders-worker-3")

This is what appears in RabbitMQ's management interface. Worth setting before you need it, which will be while working out who is holding a message.

A different codec

acemq.ConsumeWith(myCodec)

See codecs.

When a handler panics

A panic is caught, the message is dead-lettered, and the consumer carries on. A panic is a bug, and a bug repeats, so retrying would produce the same panic until the attempts ran out — meanwhile the consumer would have died and every other message with it.

Stopping

sub.Close()

Stops delivery and waits for handlers already running, for up to DrainTimeout — twenty seconds by default. A message being worked on when Close is called is finished and acknowledged rather than abandoned for the broker to hand to somebody else. One still running at the bound has its context cancelled, and Close returns an error matching acemq.ErrDrainTimeout; its message goes back to the broker and is redelivered. Closing twice is not an error.

mq.Close() closes every consumer on the connection the same way, so a deferred mq.Close() is usually all a program needs:

mq, err := acemq.Connect(ctx, url)
if err != nil {
    return err
}
defer mq.Close()

What Close waits for is only the handlers already running. A delivery the transport had already handed over but no handler had started is returned to the broker, requeued, rather than run — so the work a shutdown has to get through is bounded by Concurrency, not by Prefetch. A retry waiting out a short backoff is inside that wait. See the lifecycle of a service for the whole shape, including a deadline.