Skip to content
Select theme

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.

Post one:

Terminal window
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 Created
content-type: application/json
content-length: 36
date: 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:

Terminal window
curl -si http://127.0.0.1:8000/nope123
HTTP/1.1 404 Not Found
content-length: 0
date: Sun, 18 Oct 2026 12:55:21 GMT
Terminal window
curl -si -X POST http://127.0.0.1:8000/links -d '{"url":"https://example.com/x"}'
HTTP/1.1 415 Unsupported Media Type
content-type: text/plain; charset=utf-8
content-length: 54
date: 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.

Three crates:

Terminal window
cargo add validator --features derive
cargo add thiserror
cargo add serde_json

After the three adds, [dependencies] reads:

Cargo.toml
[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.

CreateLink gains a derive and an attribute:

src/main.rs
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.

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:

src/main.rs
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.

create_link swaps its extractor and its error type:

src/main.rs
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:

src/main.rs
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(),
}))
}
src/main.rs
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 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 Entity
content-type: application/json
{"error":"url: must be a valid URL"}
"not a url" ->
422 Unprocessable Entity
content-type: application/json
{"error":"url: must be a valid URL"}
unknown code ->
404 Not Found
content-type: application/json
{"error":"not found"}
database down ->
500 Internal Server Error
content-type: application/json
{"error":"internal server error"}
malformed body ->
400 Bad Request
content-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.

Back in the real service, cargo run, then the same bad URL that opened the article:

Terminal window
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 Entity
content-type: application/json
content-length: 36
date: 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:

Terminal window
curl -si -X POST http://127.0.0.1:8000/links -d '{"url":"https://example.com/x"}'
HTTP/1.1 415 Unsupported Media Type
content-type: application/json
content-length: 66
date: Sun, 18 Oct 2026 12:47:37 GMT
{"error":"Expected request with `Content-Type: application/json`"}
Terminal window
curl -si -X POST http://127.0.0.1:8000/links \
-H 'content-type: application/json' \
-d '{"url":'
HTTP/1.1 400 Bad Request
content-type: application/json
content-length: 103
date: 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:

Terminal window
curl -si http://127.0.0.1:8000/nope123
HTTP/1.1 404 Not Found
content-type: application/json
content-length: 21
date: 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:

Terminal window
docker stop shortlink-pg && time curl -si http://127.0.0.1:8000/links
HTTP/1.1 500 Internal Server Error
content-type: application/json
content-length: 33
date: Sun, 18 Oct 2026 12:48:07 GMT
{"error":"internal server error"}
real 0m3.022s

Three 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:

Terminal window
docker start shortlink-pg && sleep 3 && curl -s http://127.0.0.1:8000/health
ok

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

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.