Skip to content

Error handling

Catch typed errors, then map every other error to a response — typed catches via ExceptionFilter, unconditional mapping via Filter, defaults via ResponseError.

Three seams turn handler errors into responses, in the order the framework tries them:

  1. ResponseError on a feature’s error type — the default mapping the framework reaches for first. Lives next to the error in the crate that owns it.
  2. ExceptionFilter — catches a single typed exception via downcast. Shipped by nest-rs-exception-filters.
  3. Filter — unconditional mapping; sees any error the inner chain raises. Shipped by nest-rs-filters, over poem’s error type. HTTP only — see below.

Both Filter and ExceptionFilter are Layer sub-traits — one method each, on HTTP — and dedup by TypeId across the global / controller / method scopes.

nest_rs::filters::Filter · nest_rs::exception_filters::ExceptionFilter
#[async_trait]
pub trait Filter: Layer {
/// HTTP entry — required, no default: a filter that targets HTTP
/// without implementing this would silently let errors through.
async fn filter(&self, req: &RequestSnapshot, error: poem::Error) -> Response;
}
#[async_trait]
pub trait ExceptionFilter: Layer {
/// The concrete exception this filter catches.
type Exception: std::error::Error + Send + Sync + 'static;
/// HTTP entry — required. Called with the downcast `Exception`.
async fn catch(&self, exception: Self::Exception) -> Response;
}

HTTP only, the same answer interceptors give: reserved per-resolver and per-message seams were removed rather than shipped unwired, so a filter_graphql or catch_ws method on an impl is an E0407 (method is not a member of trait). A global filter still covers a GraphQL POST or a WS upgrade through its HTTP entry, because both are HTTP requests. Inside GraphQL, an operation’s error is shaped by its own error handling; inside WS, by the message error frame.

Terminal window
cargo add nest-rs --features exception-filters,filters

Two features because they are two crates: filters maps an inner error to a transport response, exception-filters catches one concrete type. Both ride http, so a default install already has them.

The default: framework error types, already mapped

Section titled “The default: framework error types, already mapped”

The plumbing failures every service runs into — database errors, input validation, authentication, OAuth wire codes — ship as framework types with their HTTP mapping already wired:

ErrorSourceMaps to
ServiceError::Db(DbErr)nest_rs::seaorm500 (body: "database error")
ServiceError::Validation(ValidationErrors)nest_rs::seaorm422
AuthError::*nest_rs::authn401 + WWW-Authenticate: Bearer
AuthError::Unavailablenest_rs::authn500, logged at error — an outage is not a credential signal
CredentialErrornest_rs::authn401 (opaque "invalid credentials")
TokenError::*nest_rs::authnRFC 6749 wire codes (400 / 401 / 500)

A service returns the framework type directly; ? flows it to the boundary; the impl decides status and body. Every row above implements poem’s ResponseError, which is what makes the ? legal — a handler returning Result<Json<T>> propagates any of them with no map_err. No per-feature error.rs for these — the framework owns them once, every feature reuses them.

crates/features/src/users/service.rs
use nest_rs::seaorm::ServiceError;
impl UsersService {
pub async fn create(&self, input: CreateUser) -> Result<User, ServiceError> {
input.validate()?; // → ServiceError::Validation
let row = active_for_new_user(input).insert(&Repo::<Users>::conn()?).await?; // → ServiceError::Db
Ok(User::from(&row))
}
}
#[post("/")]
async fn create(&self, Valid(input): Valid<Json<CreateUser>>)
-> Result<Json<User>, ServiceError>
{
Ok(Json(self.svc.create(input).await?))
}

Write a per-feature error only when the failure is genuinely domain-specific — a wire contract a consumer reads, a security-critical opaque variant. In that case the type lives in <feature>/error.rs and its ResponseError impl sits next to it (the features crate already links poem because it ships HTTP controllers). Most features never need this — they compose the framework’s types.

Route-bound filters via #[use_filters(...)]

Section titled “Route-bound filters via #[use_filters(...)]”

Sometimes the mapping depends on the route, not the error. Maybe /v1/widgets/boom should return an I'm a teapot while every other 500 stays a 500. That’s what #[use_filters(...)] is for.

apps/api/src/filter.rs
use nest_rs::core::{Layer, injectable};
use nest_rs::filters::{Filter, RequestSnapshot, async_trait};
use nest_rs::http::poem::{http::StatusCode, Error, Response};
#[injectable]
#[derive(Default)]
pub struct TeapotFilter;
impl Layer for TeapotFilter {}
#[async_trait]
impl Filter for TeapotFilter {
async fn filter(&self, _req: &RequestSnapshot, _error: Error) -> Response {
Response::builder()
.status(StatusCode::IM_A_TEAPOT)
.body("filtered")
}
}

Bind it per route or per controller — or globally:

the three scopes — global, controller, route
// Global, in main.rs
App::builder()
.use_filters_global([filter::<TeapotFilter>()])
.module::<AppModule>()
// Per-scope, in a controller
#[controller(path = "/widgets")]
pub struct WidgetController;
#[routes]
impl WidgetController {
#[get("/boom")]
#[use_filters(TeapotFilter)] // route scope
async fn boom(&self) -> poem::Result<&'static str> {
Err(Error::from_status(StatusCode::INTERNAL_SERVER_ERROR))
}
}
#[controller(path = "/gadgets")]
#[use_filters(TeapotFilter)] // controller scope
pub struct GadgetController;

A filter sees a RequestSnapshot (method + URI + headers), not the live Request — the inner endpoint has already consumed it. Snapshot the routing-relevant bits up front, pass them to the renderer.

Filters are #[injectable] providers, listed in providers = [...] and resolved from the container — same shape as guards and interceptors.

Filter rewrites every error. When the rewrite should depend on the concrete error type — DomainError becomes 422, every other 500 stays 500 — reach for ExceptionFilter. It declares the type it claims via the Exception associated type and only fires on a matching downcast; unmatched errors fall through to the next exception filter, then to any outer Filter, then to the transport’s default renderer.

apps/api/src/exception_filter.rs
use nest_rs::core::{Layer, injectable};
use nest_rs::exception_filters::{ExceptionFilter, async_trait};
use nest_rs::http::poem::error::ResponseError;
use nest_rs::http::poem::{http::StatusCode, Response};
#[derive(Debug, thiserror::Error)]
#[error("domain failure: {0}")]
pub struct DomainError(pub String);
// The filter claims by downcast, so the error has to reach the chain as a
// `poem::Error` first — that is what this impl is for.
impl ResponseError for DomainError {
fn status(&self) -> StatusCode {
StatusCode::INTERNAL_SERVER_ERROR
}
}
#[injectable]
#[derive(Default)]
pub struct DomainErrorFilter;
impl Layer for DomainErrorFilter {}
#[async_trait]
impl ExceptionFilter for DomainErrorFilter {
type Exception = DomainError;
async fn catch(&self, err: DomainError) -> Response {
Response::builder()
.status(StatusCode::UNPROCESSABLE_ENTITY)
.body(format!("caught: {err}"))
}
}

Bind it like any other layer: globally with use_exception_filters_global([exception_filter::<DomainErrorFilter>()]), on a controller with #[use_exception_filters(DomainErrorFilter)], or beside a verb. The handler raises the exception the way it raises any other error:

crates/features/src/users/http/controller.rs
#[get("/domain")]
#[use_exception_filters(DomainErrorFilter)]
async fn domain(&self) -> Result<&'static str, DomainError> {
Err(DomainError("boom".into()))
}

That answers 422 with caught: domain failure: boom. Drop the #[use_exception_filters] line and the same handler answers 500 from its ResponseError status: the filter replaces the default, it does not create it.

Both Filter and ExceptionFilter are Layers, so the dedup story matches every other layer kind: a global declaration and a per-scope redeclaration collapse to one execution.

Both are composed the same way, so the dedup covers every pair of scopes — including controller + method with no global in play. compose_chain resolves global + controller + method by TypeId and the survivors go into a single chain endpoint per route (ExceptionFiltersEndpoint for the typed catches, FilterChain for the untyped ones). The per-scope wraps do not nest; there is one wrap holding a deduped list.

A global Filter additionally attaches a transport-edge HttpEndpointWrap, which is what lets it map errors from paths no route matched. A global ExceptionFilter has no such wrap — see the scope note above.

The same defense-in-depth rationale as guards applies: a portable controller that needs a domain filter should redeclare it, the dedup keeps the cost at zero.

A route-site exception-filter sits closest to the handler (typed catch); global filters fold at the transport edge and see the whole inner tree. A mapped Err is tagged MappedError so DbContext still rolls back. The full family-by-family nesting is on the request lifecycle.

LayerWhere it livesWhen to reach for it
ResponseError on an error typeFramework crate that owns the error (nest-rs-seaorm, nest-rs-authn), or <feature>/error.rs for a domain-specific variantDefault. Every handler returning that error gets the same mapping
ExceptionFilterA providerThe mapping depends on the error type — DomainError → 422 even when the surrounding handler returns poem::Error for everything else
#[use_filters(F)]A providerThe mapping depends on the route — a debug endpoint, a special status, a custom body envelope

Reach for ResponseError first. An ExceptionFilter earns its keep when the behaviour follows a concrete error type across many routes; a Filter when it follows a single route shape independent of the error.

A small filter-only case: validation envelopes

Section titled “A small filter-only case: validation envelopes”

The framework’s pipe error renderer already returns an RFC 9457 application/problem+json body:

{ "type": "…#status.400", "title": "Bad Request", "status": 400, "detail": "…", "errors": { /* ... */ } }

If your API uses a different envelope ({ ok: false, error: { ... } }), write one filter that intercepts the error path and re-renders, bind it globally. The error itself stays pure.