Errors
A handler returning Err(MyError) should produce a typed JSON body and
the right status — never a stack trace, never a generic 500. The HTTP
surface gives two paths to get there. A feature with its own error enum
implements poem’s ResponseError once, and ?
carries the rest. A glue route without an enum picks up ProblemDetails
— a small builder that emits an RFC 9457 application/problem+json
body.
For a wider tour of the three error-handling seams (typed catches,
unconditional mapping, the ResponseError default), see
Error handling.
Map a feature error with ResponseError
Section titled “Map a feature error with ResponseError”The pattern is thiserror::Error for the error type,
poem’s ResponseError for the status:
use poem::http::StatusCode;use poem::error::ResponseError;use thiserror::Error;
#[derive(Debug, Error)]pub enum GreetError { #[error("name must not be empty")] EmptyName, #[error("name reserved")] Reserved,}
impl ResponseError for GreetError { fn status(&self) -> StatusCode { match self { GreetError::EmptyName => StatusCode::BAD_REQUEST, GreetError::Reserved => StatusCode::CONFLICT, } }}
#[post("/")]async fn shout(&self, Json(input): Json<GreetInput>) -> Result<Json<GreetReply>, GreetError> { if input.name.trim().is_empty() { return Err(GreetError::EmptyName); } Ok(Json(GreetReply { greeting: format!("HELLO, {}", input.name.to_uppercase()) }))}The ? operator carries the error through any layer of From
conversions; the outermost ResponseError impl decides the status and
the body. Override fn as_response(&self) -> Response on the impl when
the body needs a custom shape (e.g. an error envelope with a code
field).
A feature owns one error type — usually the one it already returns from
its service. The framework’s plumbing errors carry their own HTTP mapping:
nest_rs_seaorm::ServiceError and the OAuth nest_rs_authn::TokenError
ship ResponseError impls, so service-layer ? works without a
feature-level conversion. (nest_rs_authn::AuthError maps itself through
IntoResponse instead — see the table below.)
Structured bodies with ProblemDetails
Section titled “Structured bodies with ProblemDetails”A glue route that doesn’t own a feature error enum can still emit a structured error body conforming to RFC 9457 — Problem Details for HTTP APIs:
use nest_rs_http::ProblemDetails;use poem::Result;use poem::web::Json;
#[get("/posts/:id")]async fn show(&self, Path(id): Path<Uuid>) -> Result<Json<Post>, ProblemDetails> { let post = self.svc.find(id).await .map_err(|e| ProblemDetails::internal().with_detail(e.to_string()))? .ok_or_else(|| { ProblemDetails::not_found() .with_detail(format!("post {id} does not exist")) .with_instance(format!("/posts/{id}")) })?; Ok(Json(post))}Constructors cover the well-known statuses — bad_request, unauthorized,
forbidden, not_found, conflict, unprocessable, internal — each
preset with a stable type URI pointing at the matching RFC 9110
section. Builders extend the body:
| Builder | Field |
|---|---|
.with_detail("…") | Free-form human-readable description |
.with_instance("/posts/17") | URI of the specific occurrence |
.with_type("urn:problem:post-invalid") | Override the type URI |
.with_title("Post invalid") | Override the title |
ProblemDetails::from_error(status, title, err) | Build from any Display-impl |
The response carries Content-Type: application/problem+json
automatically; an absent detail or instance is omitted from the body
(per RFC 9457). Body shape, when all fields are set:
{ "type": "https://www.rfc-editor.org/rfc/rfc9110#status.404", "title": "Not Found", "status": 404, "detail": "post 9c3d… does not exist", "instance": "/posts/9c3d…"}When to pick which
Section titled “When to pick which”| Situation | Use |
|---|---|
| The error is a variant of a feature’s own enum, used by ≥ 2 routes | ResponseError on the enum |
| A one-off route raises a problem the enum doesn’t model (e.g. a glue route checking a precondition) | ProblemDetails inline |
The service returns a framework error (ServiceError, TokenError, AuthError, …) | Nothing — the framework’s own mapping already renders it |
| The body shape must match an external spec (CloudEvents, your own envelope) | Override as_response on the feature error’s ResponseError impl |
A feature’s own error type is the canonical home. ProblemDetails is
the right tool for the leftovers — never the goal.
Framework errors carry their own mapping
Section titled “Framework errors carry their own mapping”The framework’s error types carry their own HTTP mapping, so they surface on a handler that calls a service without a single line of conversion code:
| Error | Source | Renders as |
|---|---|---|
nest_rs_seaorm::ServiceError | Repo, CrudService, dataloader | problem+json — 422 validation/invalid, 409 conflict, 403 forbidden, 404 not found, 500 else (validation field errors ride as errors) |
nest_rs_authn::TokenError | OAuth token endpoint | ResponseError — 400 bad grant/scope, 401 invalid credentials, 500 signing/backend |
nest_rs_authn::AuthError | Strategy authentication | IntoResponse — 401 challenge (500 when the identity store is unavailable) |
Every error the HTTP transport renders — a handler’s ServiceError, an
inline ProblemDetails, a validation rejection, and even a raw router
error (an unmounted-route 404, a 413 over the body cap) — comes back
as one application/problem+json body. The transport lifts any leftover
plain-text error onto the RFC 9457 envelope at its edge; internal (5xx)
detail is dropped so a driver or panic message never reaches the wire.
The two ResponseError types convert into poem::Error, so ?
propagates them through a poem::Result handler. AuthError implements
IntoResponse rather than ResponseError: it is returned as a handler’s
own error type (Result<T, AuthError>), not ?-propagated into a
poem::Result.
There is no AbilityError — an ability denial is not an error. It is
a guard denial, returned as an Ok(403) response by AuthzGuard, so it
never travels the ?/ResponseError path at all (see
Guards).
A feature only writes its own error enum for genuinely domain-specific wire contracts (a custom error code clients key on) or for security-opaque variants — anything the framework already speaks should keep using the framework’s mapping.
Going further
Section titled “Going further”- Error handling — the wider story:
ExceptionFilterfor typed catches,Filterfor unconditional mapping,ResponseErroras the default. - Responses — the success side, and how
#[http_code]leaves a handler’sErrstatus untouched. - Controllers & routes — the route table.
Built by YV17labs