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.

Redelivery is immediate, and there is no backoff yet. A handler that nacks a transient failure — a dependency that is down for a second — burns a MaxDeliver of five in milliseconds, which makes the budget useless for the case it exists for. Until backoff lands, a handler that expects transient failures should absorb the wait itself before it nacks, or size MaxDeliver for the retries it actually wants.

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

Released and LeaseExpired are attempt-grain: one message can be released, or reach the bound, more than once, so neither is a message count. Exhausted and Stranded are message-grain. 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.

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 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. 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. One path, not two.