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 |
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::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. 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(Json(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};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”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 / controllers — custom errors with
?— 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.
Built by YV17labs