Skip to content

Error handling

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. On HTTP it builds on poem’s error type; on GraphQL on async-graphql’s.

Both Filter and ExceptionFilter are Layer sub-traits — one impl, three transports — and dedup by TypeId across the global / controller / method scopes.

#[async_trait]
pub trait Filter: Layer {
/// HTTP entry — required.
async fn filter(&self, req: &RequestSnapshot, error: poem::Error) -> Response;
/// GraphQL entry. Default returns the error unchanged.
async fn filter_graphql<'a>(&self, ctx: &GraphqlContext<'a>, error: GraphqlError)
-> GraphqlError { error }
/// WS entry. Default returns the error unchanged.
async fn filter_ws(&self, client: &WsClient, event: &str, error: String) -> String { error }
}
#[async_trait]
pub trait ExceptionFilter: Layer {
type Exception: std::error::Error + Send + Sync + 'static;
/// HTTP entry — required. Called with the downcast `Exception`.
async fn catch(&self, exception: Self::Exception) -> Response;
/// GraphQL entry. Default reformats `exception` as a plain message.
async fn catch_graphql<'a>(&self, ctx: &GraphqlContext<'a>, exception: &Self::Exception)
-> GraphqlError { GraphqlError::new(exception.to_string()) }
/// WS entry. Default returns `{"error": "<message>"}`.
async fn catch_ws(&self, client: &WsClient, event: &str, exception: &Self::Exception)
-> JsonValue { json!({ "error": exception.to_string() }) }
}

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
CredentialErrornest_rs_authn401 (opaque "invalid credentials")
TokenError::*nest_rs_authn::oauthRFC 6749 wire codes (400 / 401 / 500)

A service returns the framework type directly; ? flows it to the boundary; the impl decides status and body. 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: CreateUserDto) -> 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(Json(input)): Valid<Json<CreateUserDto>>)
-> 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.

use nest_rs_core::{Layer, injectable};
use nest_rs_filters::{Filter, RequestSnapshot};
use poem::{async_trait, 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.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.

use nest_rs_core::{Layer, injectable};
use nest_rs_exception_filters::ExceptionFilter;
use poem::{async_trait, http::StatusCode, Response};
#[derive(Debug, thiserror::Error)]
#[error("domain failure: {0}")]
pub struct DomainError(pub String);
#[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.

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.

  • ExceptionFilter rides the RouteShaper chain (global + controller + method scopes resolved through compose_chain), so the dedup covers every pair of scopes — including controller + method without a global in play.
  • Filter dedups against the global chain at mount time: a controller- or method-scope wrap is skipped when its TypeId is already seeded as Global, and the transport-level HttpInterceptorMeta wrap carries the single execution. With no global in play the per-scope wraps still nest, but the inner wrap rewrites the error to a successful Response, so the outer wrap only ever sees the success path — net effect, one execution.

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.

Filter and ExceptionFilter are not the same slot — see Guards / Ordering inside a route for the full nesting diagram.

Filter (#[use_filters]) wraps the handler endpoint. On the success path it is transparent — it delegates straight inward. On the error path it maps poem::Error to a Response. Per-route, inner → outer:

handler → ability shaper → per-route interceptors → per-route filters → RouteShaper → meta

So per-route Filter sits inside the RouteShaper guards. A guard denial never reaches it — guards short-circuit with a typed Denial before next.run.

Global Filter (use_filters_global) wraps outside global guards and therefore sees errors from the whole inner tree — handler, guards, interceptors.

ExceptionFilter does not use that endpoint wrap. It runs inside RouteShaper after next.run returns Err, attempting typed downcasts before the error bubbles to outer Filter wraps.

Error path (inner → outer): handler Err → ability shaper → per-route interceptors → per-route FilterRouteShaper exception filters → global Filter → global interceptors (post-handler, e.g. transaction commit).

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 typeDomainError → 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:

{ "statusCode": 400, "error": "Bad Request", "message": "...", "details": { /* ... */ } }

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.