Skip to content

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.

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.)

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:

BuilderField
.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…"
}
SituationUse
The error is a variant of a feature’s own enum, used by ≥ 2 routesResponseError 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.

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:

ErrorSourceRenders as
nest_rs_seaorm::ServiceErrorRepo, CrudService, dataloaderproblem+json422 validation/invalid, 409 conflict, 403 forbidden, 404 not found, 500 else (validation field errors ride as errors)
nest_rs_authn::TokenErrorOAuth token endpointResponseError400 bad grant/scope, 401 invalid credentials, 500 signing/backend
nest_rs_authn::AuthErrorStrategy authenticationIntoResponse401 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.

  • Error handling — the wider story: ExceptionFilter for typed catches, Filter for unconditional mapping, ResponseError as the default.
  • Responses — the success side, and how #[http_code] leaves a handler’s Err status untouched.
  • Controllers & routes — the route table.

Built by YV17labs