axum-errors-extractor-rejections
Overview
Every Axum extractor that can fail carries an associated Rejection type. When
extraction fails, Axum does not bubble a Service error: it turns the
rejection straight into an HTTP response. Both extractor traits bound the
rejection with type Rejection: IntoResponse, which is the mechanism behind
Axum's infallible-handler model.
Built-in extractors ship predefined rejections in the axum::extract::rejection
module. The default rejection response is plain text with a sensible status
code. This skill covers the four ways to replace that default with your own
shape, and the Axum 0.8 change to Option<T> extractor behavior.
The rejection module and customization APIs are identical across Axum 0.7 and
0.8. The single version divergence is Option<T> extractor behavior (see the
decision tree below).
Quick Reference
| Built-in extractor | Rejection enum | Default status family |
|---|---|---|
Json<T> |
JsonRejection |
400 / 415 / 422 |
Path<T> |
PathRejection |
400 |
Query<T> |
QueryRejection |
400 |
Form<T> |
FormRejection |
400 / 415 |
Bytes |
BytesRejection |
400 |
String |
StringRejection |
400 |
Extension<T> |
ExtensionRejection |
500 |
Every rejection enum exposes the same two inherent methods:
| Method | Signature | Purpose |
|---|---|---|
status |
pub fn status(&self) -> StatusCode |
The status Axum would have used |
body_text |
pub fn body_text(&self) -> String |
The plain-text error message |
Customization techniques, lightest to heaviest:
| Technique | Crate / feature | Use when |
|---|---|---|
Result<T, T::Rejection> handler arg |
core axum |
One or two handlers need bespoke handling |
WithRejection<E, R> |
axum-extra + with-rejection |
Remap a built-in rejection across many handlers |
#[derive(FromRequest)] + rejection(...) |
axum + macros |
Remap with zero boilerplate, wrapper also a named type |
| Hand-written custom extractor | core axum |
Extraction must read other request parts |
Core rules:
- ALWAYS include a
_ =>catch-all arm when matching a rejection enum. Every rejection enum is#[non_exhaustive]. - ALWAYS reuse
rejection.status()andrejection.body_text()to preserve Axum's correct status and message. NEVER invent your own status code. - NEVER assume
Option<Extractor>swallows all errors on Axum 0.8. It rejects on malformed input. - A custom extractor's
Rejectiontype MUST implementIntoResponse. - NEVER implement both
FromRequestandFromRequestPartsfor the same concrete type, except for a generic wrapper around another extractor.
Decision Trees
Which customization technique
Does extraction itself need to read other request parts
(MatchedPath, headers) to build the error?
YES: hand-write a custom extractor. Set its Rejection type
and build the response in from_request / from_request_parts.
NO: does more than one or two handlers need the same custom error?
NO: type the handler argument as Result<Extractor, Rejection>
and match on the rejection inside the handler body.
YES: do you also want the wrapper usable as a named type
and zero manual trait impls?
YES: #[derive(FromRequest)] with #[from_request(via(...), rejection(R))].
NO: WithRejection<Extractor, R> from axum-extra.
Option extractor behavior: 0.7 vs 0.8
On which Axum version does the Option<Extractor> handler argument run?
0.7: Option<T> swallows ALL failure conditions and yields None.
0.8: Option<T> is backed by OptionalFromRequest(Parts).
Genuinely absent value -> None.
Present but malformed value -> rejection (4xx).
After a 0.7 to 0.8 upgrade, ALWAYS audit every Option<Extractor> handler
argument. Code that relied on swallow-everything now returns 4xx where it
previously returned None. To restore the old behavior, use
Result<T, T::Rejection> and map Err(_) to your own default.
Patterns
Pattern: match a rejection enum with the mandatory catch-all
Every rejection enum is #[non_exhaustive], so Axum can add failure variants
in a minor release. A match without a _ => arm does not compile.
// axum 0.7 / 0.8 - identical
use axum::extract::rejection::JsonRejection;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
fn render(rejection: JsonRejection) -> Response {
match rejection {
JsonRejection::MissingJsonContentType(_) =>
(StatusCode::UNSUPPORTED_MEDIA_TYPE, "expected application/json")
.into_response(),
JsonRejection::JsonSyntaxError(_) =>
(StatusCode::BAD_REQUEST, "invalid JSON syntax").into_response(),
JsonRejection::JsonDataError(_) =>
(StatusCode::UNPROCESSABLE_ENTITY, "JSON does not match schema")
.into_response(),
// #[non_exhaustive] makes this arm mandatory
rejection => (rejection.status(), rejection.body_text()).into_response(),
}
}
Pattern: Result<T, T::Rejection> handler argument
The lightest customization. No custom extractor, no extra crate. Type the
handler argument as Result<Extractor, Rejection>; Axum hands you the
Result so you match inside the handler.
// axum 0.7 / 0.8 - identical
use axum::{extract::rejection::JsonRejection, Json, response::Response};
use serde_json::Value;
async fn create(payload: Result<Json<Value>, JsonRejection>) -> Response {
match payload {
Ok(Json(value)) => Json(value).into_response(),
Err(rejection) => render(rejection), // see catch-all pattern above
}
}
ALWAYS use this when only one or two handlers need bespoke handling. NEVER
scatter it across every handler in a large API; use WithRejection or a
derived rejection instead.
Pattern: WithRejection<E, R> for codebase-wide remapping
axum-extra's WithRejection<E, R> runs extractor E and, on failure,
converts its rejection into your type R. R MUST implement IntoResponse
and From<E::Rejection>. Requires the with-rejection feature on axum-extra.
// axum 0.7 / 0.8 - identical, requires axum-extra "with-rejection" feature
use axum::{extract::rejection::JsonRejection, Json, response::{IntoResponse, Response}};
use axum_extra::extract::WithRejection;
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error(transparent)]
JsonRejection(#[from] JsonRejection), // #[from] generates From<JsonRejection>
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match self {
ApiError::JsonRejection(r) => (r.status(), r.body_text()),
};
(status, Json(json!({ "message": message }))).into_response()
}
}
async fn handler(WithRejection(Json(value), _): WithRejection<Json<Value>, ApiError>) {
let _ = value; // the successfully extracted value
}
The second tuple field is a PhantomData<R> placeholder; discard it with _.
Pattern: derive(FromRequest) with rejection(...)
#[derive(FromRequest)] builds an extractor that delegates to another via
via(...) and routes its rejection through rejection(...), with no manual
trait impl. Requires the macros feature on axum.
// axum 0.7 / 0.8 - identical, requires axum "macros" feature
use axum::extract::FromRequest;
use axum::extract::rejection::JsonRejection;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde_json::json;
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
pub struct Json<T>(pub T);
pub struct ApiError { status: StatusCode, message: String }
impl From<JsonRejection> for ApiError {
fn from(r: JsonRejection) -> Self {
ApiError { status: r.status(), message: r.body_text() }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
(self.status, axum::Json(json!({ "message": self.message }))).into_response()
}
}
via(...) names the inner extractor; rejection(...) names the type the inner
rejection is converted into via From. A body-consuming extractor derives
FromRequest; a parts-only one derives FromRequestParts.
Pattern: hand-written custom extractor
Use this only when extraction must read other request parts. The wrapper
declares its own Rejection type, which MUST implement IntoResponse.
// axum 0.7 / 0.8 - identical
use axum::extract::{FromRequest, MatchedPath, Request, rejection::JsonRejection};
use axum::http::StatusCode;
use serde::de::DeserializeOwned;
use serde_json::{json, Value};
pub struct Json<T>(pub T);
impl<S, T> FromRequest<S> for Json<T>
where
axum::Json<T>: FromRequest<S, Rejection = JsonRejection>,
T: DeserializeOwned,
S: Send + Sync,
{
type Rejection = (StatusCode, axum::Json<Value>);
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let path = req.extensions().get::<MatchedPath>()
.map(|p| p.as_str().to_owned());
match axum::Json::<T>::from_request(req, state).await {
Ok(axum::Json(value)) => Ok(Self(value)),
Err(rejection) => {
let payload = json!({ "message": rejection.body_text(), "path": path });
Err((rejection.status(), axum::Json(payload)))
}
}
}
}
See references/examples.md for the full verified version of every pattern.
Reference Links
references/methods.md: verifiedFromRequest/FromRequestPartstrait definitions, the fullaxum::extract::rejectionmodule inventory (11 enums, 14 structs),JsonRejectiondetail,WithRejection<E, R>signature, the#[from_request]attribute keys, and theOptionalFromRequest(Parts)traits.references/examples.md: complete version-annotated working code for all six patterns, including theOption<Path<T>>0.7-vs-0.8 contrast.references/anti-patterns.md: real mistakes with root-cause analysis: the missing catch-all arm, inventing a wrong status, the 0.8Optiontrap, scatteringResultarguments, the double-trait-impl error, and missing feature flags.
Related skills: axum-syntax-extractors (the full extractor set),
axum-syntax-custom-extractors (writing FromRequest by hand),
axum-errors-handling (application-level error types and IntoResponse),
axum-errors-handler-trait (diagnosing Handler is not satisfied).