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.
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 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.)
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 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:
| 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.
An error the client is not owed
Section titled “An error the client is not owed”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.
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.
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::oauth::server::TokenError | OAuth token endpoint | ResponseError — application/json per RFC 6749 §5.2 — 400 bad grant/scope, 401 invalid_client, 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.