lilydocs
HTTP APIWebSocketConsumer
Pipeline4 min read

Guards and authorization

Allow or reject processing before the handler by using services from the delivery scope and an explicit failure class.

A guard decides whether an admitted message may reach its handler. It runs after all middleware entries and before resolving the handler owner and typed extractors. Its result is Result<(), QueueHandlerError>, rather than a Boolean.

A guard with a scoped policy#

This complete application prepares audit data in middleware, resolves OrderPolicy in a guard, and passes the same Scoped instance to the handler. The version check is a small application policy, not credential verification. Your own service evaluates verified identities and domain permissions for real authorization.

guards.rs
use std::sync::Arc;
use lilyrs::consumer::{Consumer, ConsumerError, Extensions, Injectable, ServiceTrait};
use lilyrs::queue::{
    DeliveryContext, Json, Local, QueueDeliveryExchange, QueueGuard, QueueHandlerError,
    QueueMiddleware, QueuePipelineComponentInitError, Service, async_trait, guard,
    queue, queue_service,
};
use serde::Deserialize;

#[derive(Clone)]
struct DeliveryAudit { event_id: String }

struct Audit;
#[async_trait]
impl QueueMiddleware for Audit {
    async fn new(_: Arc<Extensions>) -> Result<Self, QueuePipelineComponentInitError> {
        Ok(Self)
    }
    async fn before_delivery(&self, exchange: &mut QueueDeliveryExchange<'_>)
        -> Result<(), QueueHandlerError>
    {
        let event_id = exchange.context().event_id().into_inner().to_string();
        exchange.insert_local(DeliveryAudit { event_id })?;
        Ok(())
    }
}

#[derive(Injectable)]
#[service(lifetime = "Scoped")]
struct OrderPolicy;
impl ServiceTrait for OrderPolicy {}
impl OrderPolicy {
    fn accepts(&self, context: &DeliveryContext) -> bool {
        context.schema_version().into_inner() == 1
    }
}

struct OrderGuard;
#[async_trait]
impl QueueGuard for OrderGuard {
    async fn new(_: Arc<Extensions>) -> Result<Self, QueuePipelineComponentInitError> {
        Ok(Self)
    }
    async fn can_activate(&self, exchange: &mut QueueDeliveryExchange<'_>)
        -> Result<(), QueueHandlerError>
    {
        let policy = exchange.service::<OrderPolicy>().await
            .map_err(|error| QueueHandlerError::retryable_with_source("POLICY_UNAVAILABLE", error))?;
        if policy.accepts(exchange.context()) { Ok(()) }
        else { Err(QueueHandlerError::permanent("ORDER_NOT_ADMITTED")) }
    }
}

#[derive(Deserialize)]
struct OrderCreated { order_id: String }
#[derive(Injectable)]
#[service(lifetime = "Singleton")]
struct OrderWorker;
impl ServiceTrait for OrderWorker {}

#[queue_service]
#[guard(OrderGuard)]
impl OrderWorker {
    #[queue("orders.created", version = 1, content = "json")]
    async fn created(&self, Service(_policy): Service<OrderPolicy>,
        Local(audit): Local<DeliveryAudit>, Json(event): Json<OrderCreated>)
        -> Result<(), QueueHandlerError>
    {
        if event.order_id.is_empty() {
            return Err(QueueHandlerError::permanent("ORDER_ID_REQUIRED"));
        }
        println!("event={}, order={}", audit.event_id, event.order_id);
        Ok(())
    }
}

#[tokio::main]
async fn main() -> Result<(), ConsumerError> {
    Consumer::builder().middleware::<Audit>().run().await
}

Download the example. Use the orders.created configuration from your first application. The manifest needs lilyrs = { version = "0.1.0", features = ["consumer"] }, serde with derive, and tokio with macros and rt-multi-thread.

Choose one registration level#

Registration Scope
.guard::<OrderGuard>() All selected handler plans
#[guard(OrderGuard)] on the impl Handlers of that queue service
#[guard(OrderGuard)] on a handler Only that handler

Order is global → service → handler. The example registers its guard on the service; do not also add it to the builder. Repeating a concrete type in one effective plan, using one type as both guard and middleware, or exceeding 64 guards causes a startup error. Each marker takes one type; repeat the marker for multiple guards.

Identity, errors and rate limiting#

RabbitMQ credentials or an mTLS connection do not make a message’s user_id a verified identity. Application middleware can verify credentials or signatures and produce Local<VerifiedActor> for the guard. There is no built-in Consumer JWT/OIDC verifier.

Choose permanent for a lasting rejection and a justified retryable for temporary dependency problems. A Redis-backed limiter can also be an application guard; retryable rate-limit rejection consumes normal retry attempts and can eventually reach the DLQ. It does not create an HTTP 403/429 response or an automatic waiting queue. Error handling explains what follows.

Lifetimes and cancellation#

The constructor runs once per concrete type during Consumer startup. Do not retain Scoped or Transient services there; resolve them through exchange.service::<T>().await during the active delivery. Cancellation and deadline are available on the exchange; do not add a separate WebSocket cancellation parameter to the callback. Bind external calls to the shared delivery budget.

Sources for this page v0.1.0

This guide is based on the reviewed framework commit.

QUICK START
Documentation
Source code