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:
ResponseErroron a feature’s error type — the default mapping the framework reaches for first. Lives next to the error in the crate that owns it.ExceptionFilter— catches a single typed exception via downcast. Shipped bynest-rs-exception-filters.Filter— unconditional mapping; sees any error the inner chain raises. Shipped bynest-rs-filters, overpoem’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.
#[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.
Install
Section titled “Install”cargo add nest-rs --features exception-filters,filtersTwo 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:
| Error | Source | Maps to |
|---|---|---|
ServiceError::Db(DbErr) | nest_rs::seaorm | 500 (body: "database error") |
ServiceError::Validation(ValidationErrors) | nest_rs::seaorm | 422 |
AuthError::* | nest_rs::authn | 401 + WWW-Authenticate: Bearer |
AuthError::Unavailable | nest_rs::authn | 500, logged at error — an outage is not a credential signal |
CredentialError | nest_rs::authn | 401 (opaque "invalid credentials") |
TokenError::* | nest_rs::authn | RFC 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.
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?))}When a feature defines its own error type
Section titled “When a feature defines its own error type”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.
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:
// Global, in main.rsApp::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 scopepub 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.
Typed catches via ExceptionFilter
Section titled “Typed catches via ExceptionFilter”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.
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:
#[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.
TypeId dedup — redeclaration is free
Section titled “TypeId dedup — redeclaration is free”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.
Where filters sit in the chain
Section titled “Where filters sit in the chain”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.
Choosing between the three seams
Section titled “Choosing between the three seams”| Layer | Where it lives | When to reach for it |
|---|---|---|
ResponseError on an error type | Framework crate that owns the error (nest-rs-seaorm, nest-rs-authn), or <feature>/error.rs for a domain-specific variant | Default. Every handler returning that error gets the same mapping |
ExceptionFilter | A provider | The mapping depends on the error type — DomainError → 422 even when the surrounding handler returns poem::Error for everything else |
#[use_filters(F)] | A provider | The 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.
Going further
Section titled “Going further”- HTTP / errors — map a feature error with
ResponseError— theResponseErrorwalkthrough. - Interceptors — the success-path sibling.
- Guards — return
Err(Response)directly, bypassing the filter chain. posts/http/exception_filter.rsin the demo — mapsPostError::AlreadyPublishedto RFC 9457application/problem+json.