Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Handlers & Context

A handler is your business logic. It receives a message, does work — writes a record, calls an API, updates state — and returns an Outcome that tells the consumer what to do next. The library calls handle() for each delivered message and routes the message based on the returned Outcome. Handlers are parameterized on a Topic, which prevents accidentally sharing a handler between two topics that happen to carry the same message type.

The MessageHandler trait

Every handler implements MessageHandler<T> where T: Topic:

pub trait MessageHandler<T: Topic>: Send + Sync + 'static {
    type Context: Clone + Send + Sync + 'static;

    fn handle(
        &self,
        message: T::Message,
        metadata: MessageMetadata,
        ctx: &Self::Context,
    ) -> impl Future<Output = Outcome> + Send;

    fn handle_dead(
        &self,
        message: T::Message,
        metadata: DeadMessageMetadata,
        ctx: &Self::Context,
    ) -> impl Future<Output = ()> + Send {
        // default: log a warning and ack
    }
}

The handle method is required. handle_dead has a default implementation that logs a warning at WARN level and acks the dead-lettered message. Override it if you need alerting or investigation logic.

Context — shared state without a registry

Most handlers need access to shared resources: a database connection pool, an HTTP client, a cache, a reference to application configuration. Context is how you inject those resources cleanly.

You supply the context once when registering the handler; the harness clones it into each consumer task. Because Clone is required, the idiomatic pattern is Arc<AppState> where cloning is a cheap reference-count increment rather than a deep copy.

When there is no shared state to inject, use ():

struct Handler;

impl MessageHandler<Orders> for Handler {
    type Context = ();

    async fn handle(&self, msg: Order, _: MessageMetadata, _: &()) -> Outcome {
        println!("order received: {:?}", msg.id);
        Outcome::Ack
    }
}

When your handler depends on a database pool or other shared resource, declare a Context type:

#[derive(Clone)]
struct AppState {
    db: Arc<Pool>,
}

struct Handler;

impl MessageHandler<Orders> for Handler {
    type Context = AppState;

    async fn handle(&self, msg: Order, _: MessageMetadata, ctx: &AppState) -> Outcome {
        match ctx.db.insert(&msg).await {
            Ok(_) => Outcome::Ack,
            Err(_) => Outcome::Retry,
        }
    }
}

// Registration:
let mut group = broker.consumer_group().with_context(state.clone());
group.register::<Orders, _>(cfg, || Handler).await?;

Context should be cheap to clone. Arc<AppState> is the canonical pattern; the clone is a refcount bump and does not copy any data.

Handler timeouts

Every handler call runs under a wall-clock deadline. If it does not return within the timeout, the future is dropped, Outcome::Retry is recorded, and metrics::record_failed(..., FailReason::Timeout) is emitted. The default is 30 seconds.

Three layers control the timeout, in priority order — the first one set wins:

  1. Per-group override<Backend>ConsumerGroupConfig::with_handler_timeout(Duration). Lives on the group config; rebuilt per register call.
  2. Registry default<Backend>ConsumerGroupRegistry::with_default_handler_timeout(Duration). Applies to every group that did not set its own. Use this when most groups in a service share a sensible deadline and a few outliers override it.
  3. Library defaultDEFAULT_HANDLER_TIMEOUT = 30s.
let mut group = broker
    .consumer_group()
    .with_default_handler_timeout(Duration::from_secs(15));

group
    .register::<Orders, _>(
        ConsumerGroupConfig::new(
            KafkaConsumerGroupConfig::new(1..=4)
                .with_handler_timeout(Duration::from_secs(60)),  // overrides the registry default for this topic
        ),
        || OrderHandler,
    )
    .await?;

Per-supervisor consumers (ConsumerSupervisor::register) use ConsumerOptions::<Backend>::new().with_handler_timeout(Duration) instead — the same three-layer resolution applies, with the supervisor's registry default sitting between per-consumer overrides and the library default.

The timeout enforces deadlines on the handler future itself. It does not cancel work that has escaped into a spawned task or a long-running blocking call; if you need to bound those, you must wire the cancellation yourself. The default is generous because the library cannot tell whether your handler is doing IO or compute — set a shorter one when you know the work is bounded.

MessageMetadata

Every handle() invocation receives a MessageMetadata describing the delivery:

  • retry_count: u32 — how many times this message has been retried; 0 on first delivery.
  • delivery_id: String — opaque delivery identifier (AMQP delivery tag, SQS receipt handle, etc.). Useful for logging.
  • redelivered: bool — whether the broker flagged this as a redelivery at the transport layer.
  • headers: HashMap<String, String> — string-valued headers attached to the delivery.

Most handlers ignore all of these fields. They become useful when you need to trace a message (headers["x-trace-id"]), implement idempotency checks (headers["x-message-id"] on RabbitMQ), or adjust behavior on redelivery. The retry_count in particular is more reliable than redelivered for detecting retries because shove increments it explicitly, whereas redelivered reflects the broker's view of the transport.

Dead-letter handling

When a message exhausts its retries or is permanently rejected, it lands in the DLQ. The handle_dead method is your hook for what happens next: send an alert, write to a quarantine store, push to an investigation queue, page an on-call engineer.

impl MessageHandler<Orders> for Handler {
    type Context = AppState;

    async fn handle(&self, msg: Order, _: MessageMetadata, _: &AppState) -> Outcome {
        // ... normal processing ...
        Outcome::Ack
    }

    async fn handle_dead(&self, msg: Order, meta: DeadMessageMetadata, ctx: &AppState) {
        tracing::error!(
            order_id = %msg.id,
            reason = meta.reason.as_deref().unwrap_or("unknown"),
            death_count = meta.death_count,
            "dead-lettered order",
        );
        // alert, write to quarantine table, etc.
    }
}

DeadMessageMetadata wraps the base MessageMetadata (accessible via meta.message) and adds reason, original_queue, and death_count.

The dead-letter consumer loop is driven separately from the main consumer loop: consumer.run_dlq::<T>(). The message is always acked from the DLQ after handle_dead returns.

Audited handlers

For compliance, debugging, or fraud investigation, you may need a record of every message delivery: what it contained, how long the handler ran, what the outcome was. MessageHandlerExt::audited(audit_handler) wraps any handler in an audit layer. The wrapped handler has the same Outcome contract and the same Context type as the original. Auditing is purely compositional — it does not change behaviour, only adds a side-channel write per invocation.

use shove::MessageHandlerExt;

let handler = OrderHandler.audited(MyAuditSink);
group.register::<Orders, _>(cfg, move || handler.clone()).await?;

See Audit Logging for a full explanation of the AuditHandler trait and audit record schema.

For the other side of the registration — how broker.consumer_group() and with_context work — see The Broker<B> Pattern.