Error handling
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. On HTTP it builds onpoem’s error type; on GraphQL onasync-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:
| 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 |
CredentialError | nest_rs_authn | 401 (opaque "invalid credentials") |
TokenError::* | nest_rs_authn::oauth | 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. 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?))}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};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.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;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.
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.
ExceptionFilterrides theRouteShaperchain (global + controller + method scopes resolved throughcompose_chain), so the dedup covers every pair of scopes — including controller + method without a global in play.Filterdedups 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-levelHttpInterceptorMetawrap carries the single execution. With no global in play the per-scope wraps still nest, but the inner wrap rewrites the error to a successfulResponse, 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.
Where filters sit in the chain
Section titled “Where filters sit in the chain”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 → metaSo 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 Filter → RouteShaper exception
filters → global Filter → global interceptors (post-handler, e.g.
transaction commit).
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:
{ "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.
Going further
Section titled “Going further”- HTTP / controllers — custom errors with
?— theResponseErrorwalkthrough. - Interceptors — the success-path sibling.
- Guards — return
Err(Response)directly, bypassing the filter chain.