Skip to content

HTTP errors

Map handler errors to HTTP responses — ResponseError on a feature error enum, ProblemDetails for one-off RFC 9457 bodies.

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:

crates/features/src/hello/error.rs
use nest_rs::http::poem::http::StatusCode;
use nest_rs::http::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::oauth::server::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:

crates/features/src/posts/http/controller.rs
use nest_rs::http::ProblemDetails;
use nest_rs::http::poem::Result;
use nest_rs::http::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.

Everything above decides how an error is shaped. Opaque decides whether the client sees it at all.

ServiceError is wire-safe by construction — its Db variant displays as "database error", never the DbErr underneath. A feature’s own error type has no such discipline imposed on it, and a Display that wraps a driver error carries SQL, column names and sometimes row values into the response body.

crates/features/src/reports/http/controller.rs
use nest_rs::http::Opaque;
#[get("/reports/:id")]
#[authorize(Read, reports::Entity)]
async fn report(&self, Path(id): Path<Uuid>) -> Result<Json<Report>> {
Ok(Json(self.svc.render(id).await.opaque()?))
}

.opaque()? logs the real error at error on nest_rs::http and returns a ProblemDetails 500 carrying a constant message. The envelope is deliberate: a client parses the same document whether or not it was told why, so an opaque failure is not identifiable by its shape alone.

Reach for it on a failure that is none of the caller’s business. A validation rejection or an authorization denial is the opposite case: those exist to be read, and travel as themselves. The same seam reads identically on GraphQL, WebSockets and MCP.

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+json — 422 validation/invalid, 409 conflict, 403 forbidden, 404 not found, 500 else (validation field errors ride as errors)
nest_rs::oauth::server::TokenErrorOAuth token endpointResponseError — application/json per RFC 6749 §5.2 — 400 bad grant/scope, 401 invalid_client, 500 signing/backend
nest_rs::authn::AuthErrorStrategy authenticationIntoResponse — 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.

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