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 (nest_rs_seaorm::ServiceError, nest_rs_authn::AuthError …) ship their own ResponseError impls, so service-layer ? works without a feature-level conversion.

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("/orders/:id")]
async fn show(&self, Path(id): Path<Uuid>) -> Result<Json<Order>, ProblemDetails> {
let order = self.svc.find(id).await
.map_err(|e| ProblemDetails::internal().with_detail(e.to_string()))?
.ok_or_else(|| {
ProblemDetails::not_found()
.with_detail(format!("order {id} does not exist"))
.with_instance(format!("/orders/{id}"))
})?;
Ok(Json(order))
}

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("/orders/17")URI of the specific occurrence
.with_type("urn:problem:order-invalid")Override the type URI
.with_title("Order 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": "order 9c3d… does not exist",
"instance": "/orders/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, AuthError, …)Nothing — the framework’s ResponseError impl already maps 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.

Three of the framework’s error types ship a ResponseError impl out of the box. They surface on ? through any handler that calls a service without writing a single line of mapping code:

ErrorSourceCommon statuses
nest_rs_seaorm::ServiceErrorRepo, CrudService, dataloader404 not found, 409 conflict, 500 else
nest_rs_authn::AuthErrorStrategy authentication401 unauthorized
nest_rs_authz::AbilityErrorAbility denial403 forbidden

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 the known limitation when #[http_code] sits on a handler that may Err.
  • Controllers & routes — the route table.