Mapping Errors to HTTP Status
Problem: a handler that executes chain-builder queries can fail three ways — the query was invalid because of request input (the client's fault), invalid because of a bug in your chain (your fault), or the database failed. Those need to become 400, 500, and 500/404 respectively, in one place, instead of ad-hoc matches in every handler.
The split is principled, not heuristic: every BuildError variant describes a caller mistake, so the only question is did the offending value come from the request? Two variants can be triggered by end-user input in a typical filter/pagination API — InvalidHavingOperator (an operator string taken from input) and OffsetWithoutLimit (pagination parameters taken from input) — they map to 400. Every other build error is a bug in query construction → 500. Error::Sqlx is a database/driver failure → 500, with RowNotFound refined to 404 where "no such row" is an expected outcome.
axum is used for illustration only — it is not a dependency of chain-builder. The match arms are the portable part; swap the
IntoResponseglue for your framework's equivalent.
The AppError wrapper
One newtype over chain_builder::Error, one From impl so ? works in every handler, one IntoResponse impl holding the whole mapping:
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
};
use chain_builder::{BuildError, Error};
struct AppError(Error);
// `?` on any fetch_*/execute/count call lands here.
impl From<Error> for AppError {
fn from(e: Error) -> Self {
Self(e)
}
}
// `?` on a bare try_to_sql()/try_to_sqlx_query() also works:
// BuildError → chain_builder::Error → AppError.
impl From<BuildError> for AppError {
fn from(e: BuildError) -> Self {
Self(Error::from(e))
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self.0 {
// ---- 4XX: the offending value came from the request ----------
// A filter API let the client pick the operator; the allowlist
// rejected it. The variant carries the rejected operator, but
// echo it back with care (it is attacker-controlled text).
Error::Build(BuildError::InvalidHavingOperator(_)) => (
StatusCode::BAD_REQUEST,
"unsupported filter operator".to_owned(),
),
// Pagination params came straight from the request: an offset
// without a limit is a client error here. If YOUR code computes
// offsets independently of input, move this arm to the 500 bucket.
Error::Build(BuildError::OffsetWithoutLimit) => (
StatusCode::BAD_REQUEST,
"offset requires a limit".to_owned(),
),
// ---- 404 refinement: expected "no such row" -------------------
// fetch_one / fetch_scalar on zero rows. Only sensible on
// lookup-by-id style endpoints; drop this arm if a missing row
// means a broken invariant instead.
Error::Sqlx(sqlx::Error::RowNotFound) => {
(StatusCode::NOT_FOUND, "not found".to_owned())
}
// ---- 5XX: programmer errors and infrastructure ----------------
// Remaining build errors (LockRequiresSelect, EmptyInsert, …) are
// bugs in query construction, not client input. Log loudly.
Error::Build(e) => {
tracing::error!(error = %e, "invalid query construction");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
}
// Connection / query / decode failure.
Error::Sqlx(e) => {
tracing::error!(error = %e, "database error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
}
// Error is #[non_exhaustive]: this wildcard arm is REQUIRED by
// the compiler outside the chain-builder crate, and it is where
// any future variant lands — default it to 500, never 200.
e => {
tracing::error!(error = %e, "unhandled chain-builder error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_owned())
}
};
(status, message).into_response()
}
}A handler then needs no error code at all:
async fn get_user(
State(pool): State<sqlx::PgPool>,
Path(id): Path<i64>,
) -> Result<Json<UserRow>, AppError> {
let user: UserRow = QueryBuilder::<Postgres>::table("users")
.select(["id", "name", "role"])
.where_eq("id", id)
.fetch_one(&pool) // no row → Error::Sqlx(RowNotFound) → 404
.await?;
Ok(Json(user))
}Notes & caveats
- Wildcard arms are mandatory, twice. Both
ErrorandBuildErrorare#[non_exhaustive]— anymatchon either, anywhere outside the crate, needs a wildcard arm. Above, the innerBuildErrormatching happens insideError::Build(…)patterns, and the trailinge => …arm covers futureErrorvariants; theError::Build(e)arm covers futureBuildErrorvariants. Route both wildcards to 500: a variant you did not anticipate is by definition not a request error you understood. - The 400 set is about your API surface.
InvalidHavingOperatorandOffsetWithoutLimitare 400 only because those values plausibly arrive from a request. If your handlers never expose operators or raw offsets to clients, map everything inError::Buildto 500 and treat any occurrence as a bug. - Don't leak internals.
BuildError'sDisplaymessages name builder methods, andsqlx::Errorcan include SQL fragments and table names — log them, but send a generic body. The one variant carrying user input,InvalidHavingOperator(String), contains attacker-controlled text; if you echo it, treat it as untrusted output (encode/escape for the response format). - 404 is a refinement, not a default.
fetch_optionalreturningOk(None)is usually the cleaner way to express "missing is fine"; theRowNotFoundarm catches thefetch_one/fetch_scalarpaths. - Prefer the
try_*/fetch_*family in handlers. The panickingto_sql()path bypasses this whole mapping — it is for static, hand-written queries (see the policy).
Related pages
- Error Handling — the variant table and the 4XX/5XX reasoning
- Executing with sqlx — which helpers return
RowNotFoundvsOk(None) - HTTP Filters & Pagination — the handler that uses this
AppError