Validating input and returning proper errors
Step 10 of 15. Step 9 finished the happy path: every row gets its own generated code and
GET /{code} redirects to the link that was actually stored. What it left behind is every
failure mode. A POST body with a bad URL is stored anyway; a missing link comes back as a
bodyless 404; a dead database comes back as a bodyless 500. The status codes are right — the
responses are not. This step gives every failure a JSON body with the right status: a
validator rule on the URL, a thiserror enum that maps each failure to its response, and an
extractor that runs the validation before the handler ever sees the input.
Start from step 9’s end state: git checkout api-ch09 in your clone of
the examples repository, inside axum-sqlx-api/,
with the Postgres container from step 5 running and DATABASE_URL exported.
What a bad URL does today
Section titled “What a bad URL does today”Post one:
curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":"not a url"}'HTTP/1.1 201 Createdcontent-type: application/jsoncontent-length: 36date: Sun, 18 Oct 2026 12:55:21 GMT
{"code":"z2IOjdc","url":"not a url"}not a url is not a URL. The service stores it, hands out a code for it, and will redirect real
visitors to it — where their browser shows an error instead of the page they were promised.
Step 4 defined CreateLink with one field and no rules, and nothing since has added any.
The failures are barely responses at all:
curl -si http://127.0.0.1:8000/nope123HTTP/1.1 404 Not Foundcontent-length: 0date: Sun, 18 Oct 2026 12:55:21 GMTcurl -si -X POST http://127.0.0.1:8000/links -d '{"url":"https://example.com/x"}'HTTP/1.1 415 Unsupported Media Typecontent-type: text/plain; charset=utf-8content-length: 54date: Sun, 18 Oct 2026 12:55:21 GMT
Expected request with `Content-Type: application/json`The 404 is ours: returning a bare StatusCode from a handler produces exactly that — a status
line and nothing else. The 415 is axum’s, from the Json extractor rejecting the request before
the handler runs, in plain text while every success comes back as JSON. An API client should not
need one parser for success and another for failure.
The dependencies
Section titled “The dependencies”Three crates:
cargo add validator --features derivecargo add thiserrorcargo add serde_jsonAfter the three adds, [dependencies] reads:
[dependencies]axum = { version = "0.8", features = ["macros"] }serde = { version = "1", features = ["derive"] }serde_json = "1"sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "postgres", "macros"] }thiserror = "2"tokio = { version = "1", features = ["macros", "rt-multi-thread"] }validator = { version = "0.21", features = ["derive"] }validator brings the rules, thiserror the error enum, and serde_json builds the one JSON
value every error body shares. Nothing else in the file changes shape this step — same routes,
same SQL, same .sqlx/ cache.
One rule on the one field
Section titled “One rule on the one field”CreateLink gains a derive and an attribute:
use serde::Deserialize;use validator::Validate;
#[derive(Deserialize, Validate)]struct CreateLink { #[validate(url(message = "must be a valid URL"))] url: String,}#[validate(url)] parses the string with the url crate, which demands a scheme: https://example.com
passes, example.com does not, and neither does https:// — a scheme alone is not a URL. Without
the message argument the failure would carry only a machine code; the message is what the client
is about to read.
Every failure, one type
Section titled “Every failure, one type”The imports at the top of src/main.rs grow to exactly this list — FromRequest and Request
for the extractor below, DeserializeOwned for its bounds, and one item from each new crate:
use axum::{ Json, Router, extract::{Extension, FromRequest, Path, Query, Request}, http::StatusCode, response::{IntoResponse, Response}, routing::get,};use serde::{Deserialize, Serialize, de::DeserializeOwned};use serde_json::json;use sqlx::{PgPool, postgres::PgPoolOptions};use thiserror::Error;use validator::{Validate, ValidationErrors};
#[derive(Debug, Error)]enum ApiError { // Extractor rejections carry their own status: 400 for malformed JSON, // 415 for a missing or wrong content-type. #[error("{1}")] BadBody(StatusCode, String), #[error("{0}")] Validation(#[from] ValidationErrors), #[error("not found")] NotFound, // The sqlx error is kept as the source for logging (step 13) but never // serialised - its message names tables and columns a client has no // business reading. #[error("internal server error")] Database(#[from] sqlx::Error), #[error("internal server error")] Exhausted,}
impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self { ApiError::BadBody(status, _) => *status, ApiError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::Database(_) | ApiError::Exhausted => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(json!({ "error": self.to_string() }))).into_response() }}
struct ValidatedJson<T>(T);
impl<S, T> FromRequest<S> for ValidatedJson<T>where T: DeserializeOwned + Validate, S: Send + Sync,{ type Rejection = ApiError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> { let Json(value) = Json::<T>::from_request(req, state) .await .map_err(|rejection| ApiError::BadBody(rejection.status(), rejection.body_text()))?; value.validate()?; Ok(ValidatedJson(value)) }}thiserror does the boring half. Each #[error("...")] attribute becomes the Display
implementation, and #[from] generates the From conversions — which is why a plain ? after a
sqlx call will shortly be enough. The interesting half is the mapping in into_response: each
variant picks its status, and the body is always the same shape, {"error": "<message>"}.
Two variants deserve a second look. BadBody carries a StatusCode inside it because extractor
rejections already know how they failed — malformed JSON is 400, a missing content-type is 415 —
and rejection.status() hands that over; body_text() is the message axum would have printed as
plain text. Database does the opposite: it keeps the sqlx::Error as its source, for step 13’s
logging, but its Display is the fixed string internal server error. The client learns that
something failed; it does not learn your table names.
ValidatedJson<T> is where validation actually happens. It is a body extractor like Json — and
like Json, it must be the last argument of a handler — but before it returns, it calls
value.validate(). The ? on that line works because of the #[from] on Validation: a failed
validation becomes an ApiError, the rejection type, and axum turns a rejection into the response
itself. A handler using this extractor cannot see an unvalidated CreateLink.
Rewiring the handlers
Section titled “Rewiring the handlers”create_link swaps its extractor and its error type:
async fn create_link( Extension(pool): Extension<PgPool>, ValidatedJson(input): ValidatedJson<CreateLink>,) -> Result<ApiResponse<Link>, ApiError> { // The seed comes from the clock; every call to next_code advances the // state, so a retried insert draws a different code. let mut state = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock is before 1970") .as_nanos() as u64;
for _ in 0..5 { let code = next_code(&mut state); match sqlx::query!( "INSERT INTO links (code, url) VALUES ($1, $2) RETURNING id, code, url", code, input.url ) .fetch_one(&pool) .await { Ok(row) => { return Ok(ApiResponse::with_status( StatusCode::CREATED, Link { code: row.code, url: row.url, }, )); } Err(err) => { // 23505: the unique index rejected the code. Any other // failure is a real error that a retry cannot fix. let retryable = err .as_database_error() .is_some_and(|db| matches!(db.kind(), sqlx::error::ErrorKind::UniqueViolation)); if !retryable { return Err(err.into()); } } } }
// Five collisions in a row out of 3.5 trillion codes is not bad luck, // it is a bug - but a 500 beats an infinite loop. Err(ApiError::Exhausted)}Two lines changed from step 9’s version: Json(input) became ValidatedJson(input), and the
non-retryable branch returns err.into() — a sqlx::Error converted into ApiError::Database,
500 with no details. The exhausted-retry fallthrough becomes ApiError::Exhausted, also a 500,
but a named one.
list_links and resolve_link only change their return types, and the manual map_err calls
disappear because #[from] covers them:
async fn list_links( Extension(pool): Extension<PgPool>, Query(p): Query<Pagination>,) -> Result<ApiResponse<LinkPage>, ApiError> { let page = p.page.unwrap_or(1).max(1) as i64; let limit = p.limit.unwrap_or(20) as i64;
let rows = sqlx::query!( "SELECT code, url FROM links ORDER BY id LIMIT $1 OFFSET $2", limit, (page - 1) * limit ) .fetch_all(&pool) .await?;
Ok(ApiResponse::ok(LinkPage { page: page as usize, limit: limit as usize, links: rows .into_iter() .map(|row| Link { code: row.code, url: row.url, }) .collect(), }))}async fn resolve_link( Path(code): Path<String>, Extension(pool): Extension<PgPool>,) -> Result<Response, ApiError> { let row = sqlx::query!("SELECT url FROM links WHERE code = $1", code) .fetch_optional(&pool) .await?;
match row { Some(row) => Ok(( StatusCode::FOUND, [("Location", row.url.as_str())], ) .into_response()), None => Err(ApiError::NotFound), }}health stays as it was — Result<&'static str, StatusCode> with a bodyless 503. Its consumer is
a load balancer reading the status line, and step 5’s acquire_timeout is what keeps that answer
fast. Nothing about it needs a body.
The mapping, without a server
Section titled “The mapping, without a server”The enum is plain logic, so you can watch it fire in a scratch cargo new without Postgres or a
port. render is scaffolding for the demo — it turns an error into the response and reads the
body back out; the service itself never does this:
use axum::http::header;use axum::response::{IntoResponse, Response};use axum::{Json, http::StatusCode};use serde::Deserialize;use serde_json::json;use thiserror::Error;use validator::{Validate, ValidationErrors};
#[derive(Deserialize, Validate)]struct CreateLink { #[validate(url(message = "must be a valid URL"))] url: String,}
#[derive(Debug, Error)]enum ApiError { #[error("{1}")] BadBody(StatusCode, String), #[error("{0}")] Validation(#[from] ValidationErrors), #[error("not found")] NotFound, #[error("internal server error")] Database(#[from] sqlx::Error), #[error("internal server error")] Exhausted,}
impl IntoResponse for ApiError { fn into_response(self) -> Response { let status = match &self { ApiError::BadBody(status, _) => *status, ApiError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY, ApiError::NotFound => StatusCode::NOT_FOUND, ApiError::Database(_) | ApiError::Exhausted => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(json!({ "error": self.to_string() }))).into_response() }}
async fn render(err: ApiError) -> String { let response: Response = err.into_response(); let status = response.status(); let content_type = response .headers() .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("-") .to_owned(); let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) .await .unwrap(); format!("{status}\ncontent-type: {content_type}\n\n{}", String::from_utf8_lossy(&bytes))}
#[tokio::main]async fn main() { for url in ["https://example.com/short", "example.com", "not a url"] { let input = CreateLink { url: url.to_string() }; match input.validate() { Ok(()) => println!("{url:?} -> accepted"), Err(e) => println!("{url:?} ->\n{}", render(e.into()).await), } } println!("unknown code ->\n{}", render(ApiError::NotFound).await); println!("database down ->\n{}", render(ApiError::Database(sqlx::Error::RowNotFound)).await); println!("malformed body ->\n{}", render(ApiError::BadBody( StatusCode::BAD_REQUEST, "Failed to parse the request body as JSON: url: EOF while parsing a value at line 1 column 7".into(), )).await);}"https://example.com/short" -> accepted"example.com" ->422 Unprocessable Entitycontent-type: application/json
{"error":"url: must be a valid URL"}"not a url" ->422 Unprocessable Entitycontent-type: application/json
{"error":"url: must be a valid URL"}unknown code ->404 Not Foundcontent-type: application/json
{"error":"not found"}database down ->500 Internal Server Errorcontent-type: application/json
{"error":"internal server error"}malformed body ->400 Bad Requestcontent-type: application/json
{"error":"Failed to parse the request body as JSON: url: EOF while parsing a value at line 1 column 7"}Every variant lands on its status, and none of the messages name a table, a column, or a crate.
See it run
Section titled “See it run”Back in the real service, cargo run, then the same bad URL that opened the article:
curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":"not a url"}'HTTP/1.1 422 Unprocessable Entitycontent-type: application/jsoncontent-length: 36date: Sun, 18 Oct 2026 12:47:37 GMT
{"error":"url: must be a valid URL"}422 rather than 400: the request was well-formed JSON, and what failed is the value inside it. The extractor rejections come back as JSON now too — the same status codes axum chose before, different bodies:
curl -si -X POST http://127.0.0.1:8000/links -d '{"url":"https://example.com/x"}'HTTP/1.1 415 Unsupported Media Typecontent-type: application/jsoncontent-length: 66date: Sun, 18 Oct 2026 12:47:37 GMT
{"error":"Expected request with `Content-Type: application/json`"}curl -si -X POST http://127.0.0.1:8000/links \ -H 'content-type: application/json' \ -d '{"url":'HTTP/1.1 400 Bad Requestcontent-type: application/jsoncontent-length: 103date: Sun, 18 Oct 2026 12:47:37 GMT
{"error":"Failed to parse the request body as JSON: url: EOF while parsing a value at line 1 column 7"}The 404 gains its body:
curl -si http://127.0.0.1:8000/nope123HTTP/1.1 404 Not Foundcontent-type: application/jsoncontent-length: 21date: Sun, 18 Oct 2026 12:47:37 GMT
{"error":"not found"}The 500 is the one worth stopping the database for. Stop the container while the server is running, then ask for the listing:
docker stop shortlink-pg && time curl -si http://127.0.0.1:8000/linksHTTP/1.1 500 Internal Server Errorcontent-type: application/jsoncontent-length: 33date: Sun, 18 Oct 2026 12:48:07 GMT
{"error":"internal server error"}
real 0m3.022sThree seconds, because the pool holds the request for acquire_timeout(3) from step 5 before it
gives up — and the body says nothing about connections or hosts. health behaves the same way it
always did, 503 in about three seconds, which is the check a load balancer actually wants. Start
the container again and the service recovers on its own — no restart needed:
docker start shortlink-pg && sleep 3 && curl -s http://127.0.0.1:8000/healthokOne response is still axum’s plain text: GET /links?page=abc returns a 400 whose body is
Failed to deserialize query string: page: invalid digit found in string, straight from the
Query extractor — step 3 showed that behaviour and it has not changed. It is truthful, just not
JSON. The same FromRequest pattern that wrapped Json wraps Query the same way if you want
the consistency; that is a few lines left as an exercise rather than padding here.
Where this leaves the service
Section titled “Where this leaves the service”Every failure now answers in the same format the successes do: a status code that means what it
says, and a JSON body a client can parse without knowing which component failed. The URL — the
one input the service accepts from strangers — cannot reach the database unless the url crate
can parse it.
The end state is tagged api-ch10 in
the examples repository — git checkout api-ch10
inside axum-sqlx-api/. cargo test still runs the encoder tests offline; the curl sequence
above needs the container up.
Next: API key authentication with middleware. A tower layer reads a header before any handler
runs, and errors raised inside middleware raise exactly the question this step’s ApiError
answers — how to get a typed failure out of code that runs outside the handler.
The track overview keeps the full list current as articles land.