Skip to content

Settle a delivery

A handler tells the bus what it thinks of a delivery by calling Ack or Nack on it. This page is how to do that, what the bus does with it today, and the two rules that hold whichever mode a subscription is in.

Say what happened

Handler: func(ctx context.Context, d messaging.Delivery) error {
    var order Order
    if err := d.Decode(&order); err != nil {
        return d.Nack() // malformed: decline it
    }

    if err := process(ctx, order); err != nil {
        return err // an error is an outcome, and it is separate from settlement
    }

    return d.Ack()
},

Ack accepts the delivery. Nack declines it. Returning without calling either is silence, and the bus records it as such rather than reading it as consent.

What the bus does with it

That depends on the subscription's Mode.

Under AtMostOnce — the default — nothing is acted on: a Nack does not bring the message back. Every settlement is still recorded, in the Settlement counters on ConsumerStats, and a handler that settles twice is refused.

Under AtLeastOnce a Nack, or silence, brings the message back, up to MaxDeliver attempts. The subscription also names, in DurableName, the identity that outlives the process: it is required in this mode on every backend, so the spec you test against memory is the spec you run against a broker. Each redelivery arrives with a higher Attempt. A handler that keeps declining exhausts the budget and the message is counted Exhausted — real data loss, reported at message grain. The same handler is correct in both modes; only what the bus does with its answer differs.

Make a declined message wait

By default a Nack brings the message back at once. A handler that nacks a transient failure, such as a dependency that is down for a second, would then use up a MaxDeliver of five in milliseconds. Give the subscription a Backoff, and each decline waits before the next attempt:

messaging.SubscriptionSpec{
    Name: "orders", Pattern: "orders.>", Shape: messaging.Fanout,
    Mode: messaging.AtLeastOnce, MaxDeliver: 5, DurableName: "orders",

    Backoff:      messaging.Backoff{Initial: time.Second, Max: time.Minute, Multiplier: 2},
    MaxNackDelay: 10 * time.Minute,
    Handler:      handle,
}

Nack now waits one second after the first attempt, two after the second, then four, capped at a minute. A handler that returns without settling has declined too, so a plain return err waits the same schedule; d.NackAfter(0) is how to ask for an immediate retry instead. A handler that has been told when to come back, by a Retry-After for example, says so for that one decline:

if retryAfter, ok := throttled(err); ok {
    return d.NackAfter(retryAfter)
}

return d.Nack()

NackAfter is allowed up to MaxNackDelay, or the schedule's Max when that is unset. On a subscription with neither, or for a delay that is negative or above that ceiling, it returns an error and records nothing, so the handler can still Nack. A delay is never quietly turned into an immediate retry.

What a delay promises: the message is not offered again before the delay has passed. It may come later, if the queue is full or the consumer is not reading. The declined attempt is spent, so it still counts towards MaxDeliver.

A delay needs a backend that can hold the message back, Capabilities.DelaysRedelivery. On one that cannot, the subscription is refused when the bus is built, as it is for a delay longer than the backend's MaxRedeliveryDelay. Neither is allowed under AtMostOnce, where nothing comes back.

If your handler is slow

A broker cannot tell slow from dead. After AckWait it assumes dead and hands the message on, and the overlap spike measured one message running in three handlers at once when that guess was wrong. SlowHandler is your choice of what happens instead:

  • ExtendLease, the default, keeps the message yours for up to MaxProcessingTime (five minutes unless you say otherwise). Your handler must be idempotent, because a partition or a failover can still hand the message on and nothing can prevent that.
  • Redeliver lets the message go while you may still be running. Your handler must be safe under concurrent execution of the same message, which usually means a lock or a unique constraint, and AckWait becomes a correctness setting: shorter than your slowest run, and duplicate work is routine.

The default is the safe one because Redeliver's failure is silent duplicate work under load. The in-memory backend never hands a message on while a handler runs, so neither setting does anything there; they are for the backends that do.

Two rules that hold in every mode

Exactly one settlement per delivery. The first call wins; a second returns ErrAlreadySettled. This holds even if you copy the Delivery — into a struct, a closure, another goroutine — because the settlement state is shared behind the value. A handler that calls both Ack and Nack has a bug, and the bus can see it.

The returned error and the settlement are separate. An error is counted and logged and does not affect settlement; the settlement is what decides redelivery once a mode acts on it. So Ack-then-return-error and Nack-then-return-nil are both legal and both mean exactly what they say. If you are used to a system where returning an error is the nack, this is the difference to hold onto.

Reading it back

st, _ := bus.Stats("orders")
st.Settlement.Acked   // handler accepted
st.Settlement.Nacked  // handler declined
st.Settlement.Silent  // handler said nothing
st.Settlement.Refused // the backend could not perform a settlement — a different axis

The first three partition Delivered. Refused is orthogonal: a nack the backend could not act on moves Nacked and Refused.

Under AtLeastOnce four more counters say where attempts and messages went:

st.Released      // attempts given up before a handler returned, owed back by the backend
st.Exhausted     // messages that ran out of attempts — lost, at message grain
st.Stranded      // messages declined whose backend could not be asked to try again; zero where the broker will
st.LeaseExpired  // attempts handed on at MaxProcessingTime while the handler may still have been running
st.DelayShortened // declines redelivered sooner than their delay asked, because the backend could not hold them that long

Released and LeaseExpired are attempt-grain: one message can be released, or reach the bound, more than once, so neither is a message count. DelayShortened is zero except on a backend with a limit on how long it can hold a message: SQS cannot hide one past twelve hours from its first receive. Exhausted and Stranded are message-grain. On a backend declaring Capabilities.RestartCanSpendUnseenAttempt, Exhausted can undercount: a restart can spend a message's last attempt where no process saw it, and then nothing counts it. messaging-nats' JetStream backend declares it; see the backend reference. All four sit beside the attempt identity, never inside it — the identity at rest is Offered == Delivered + Released + Lost. LeaseExpired in particular changes nothing in the identity: the attempt it counts is still running and is Delivered when its handler returns. It says only that a duplicate is now possible, which is exactly what you need to see when ExtendLease ran out of road.

ForeignConflicts, under any mode, is attempts that arrived with a transport header contradicting the event's envelope, such as a broker's correlation ID that differs from the one in a MassTransit envelope. It is attempt-grain and outside the identity too. The event is still the envelope's; the other value is in d.Foreign().Headers. Binary and structured mode never raise it.

If you write a backend

Construct deliveries through NewDelivery with a DeliveryOptions, never as a struct literal. The settlement state is unexported precisely so that copies cannot disagree, and NewDelivery is where a backend hands the bus its Attempt count and its OnSettle or OnSettleWith callback. See the backend reference.

The conformance suite holds an AtLeastOnce backend to four rows — redelivery with an increasing attempt, MaxDeliver and Exhausted, per-message acknowledgement, and a refused settlement after retirement — and a fifth, a declined message waiting out its delay, on a backend declaring DelaysRedelivery. It drives the backend at the seam with no bus above it, so it completes each exchange with messaging.Settle, the same function the bus calls after a handler returns, or messaging.SettleWith to put a delay on a decline. One path, not two.