Introduction

Axum extractors like Json<T>, Form<T>, Query<T>, and Path<T> automatically reject requests that cannot be parsed or validated. By default, rejections return a generic 400 Bad Response with minimal information, making it difficult for API consumers to understand what went wrong. Without custom rejection handling, clients receive unhelpful error messages like Failed to deserialize the JSON body without details about which field failed or what was expected.

Symptoms

  • API returns generic 400 Bad Request with no detail about the error
  • JSON parse errors do not indicate which field failed
  • Query parameter validation failures not reported to client
  • Path parameter type mismatch returns empty 400 response
  • Custom validation logic cannot return structured error responses

Default rejection response: ``` HTTP/1.1 400 Bad Request content-length: 34 content-type: text/plain

Failed to deserialize the JSON body ```

Common Causes

  • Default extractor rejection not customized
  • Json<T> deserialization errors not converted to structured responses
  • Multiple extractors failing, only first rejection returned
  • Custom extractor not implementing FromRejection correctly
  • Error response not matching API error format

Step-by-Step Fix

  1. 1.**Handle Json extractor rejection with detailed error":
  2. 2.```rust
  3. 3.use axum::{
  4. 4.extract::rejection::JsonRejection,
  5. 5.http::StatusCode,
  6. 6.response::IntoResponse,
  7. 7.Json,
  8. 8.};
  9. 9.use serde::{Deserialize, Serialize};

#[derive(Deserialize)] struct CreateUser { name: String, email: String, age: Option<u32>, }

async fn create_user( // Use Result<Json<T>, JsonRejection> instead of Json<T> directly result: Result<Json<CreateUser>, JsonRejection>, ) -> impl IntoResponse { let Json(payload) = result.map_err(|err| { ( StatusCode::BAD_REQUEST, Json(ApiError { error: "Invalid request body".to_string(), message: err.to_string(), // Include serde_json error details details: Some(err.body_text()), }), ) })?;

// Process valid payload (StatusCode::CREATED, Json(CreateUserResponse { id: 1, name: payload.name, email: payload.email, })) } ```

  1. 1.Create a custom extractor wrapper with better errors:
  2. 2.```rust
  3. 3.use axum::{
  4. 4.async_trait,
  5. 5.body::Body,
  6. 6.extract::FromRequest,
  7. 7.http::{Request, StatusCode},
  8. 8.response::{IntoResponse, Response},
  9. 9.Json,
  10. 10.};
  11. 11.use serde::de::DeserializeOwned;
  12. 12.use serde_json::json;

#[derive(Debug)] struct ValidatedJson<T>(T);

#[async_trait] impl<S, T> FromRequest<S> for ValidatedJson<T> where S: Send + Sync, T: DeserializeOwned, Json<T>: FromRequest<S, Rejection = axum::extract::rejection::JsonRejection>, { type Rejection = (StatusCode, Json<serde_json::Value>);

async fn from_request(req: Request<Body>, state: &S) -> Result<Self, Self::Rejection> { match Json::<T>::from_request(req, state).await { Ok(Json(value)) => Ok(ValidatedJson(value)), Err(rejection) => { let error_response = match &rejection { axum::extract::rejection::JsonRejection::JsonSyntaxError(error) => { json!({ "error": "JSON syntax error", "message": error.to_string(), }) } axum::extract::rejection::JsonRejection::JsonDataError(error) => { json!({ "error": "JSON validation error", "message": error.to_string(), }) } _ => json!({ "error": "Invalid JSON", "message": rejection.to_string(), }), };

Err((StatusCode::BAD_REQUEST, Json(error_response))) } } } }

// Usage in handler async fn create_user(ValidatedJson(payload): ValidatedJson<CreateUser>) -> impl IntoResponse { (StatusCode::CREATED, Json(payload)) } ```

  1. 1.Add request validation with structured errors:
  2. 2.```rust
  3. 3.use thiserror::Error;

#[derive(Error, Debug)] enum ValidationError { #[error("Field '{0}' is required but was empty")] RequiredField(String), #[error("Field '{0}' must be at least {1} characters")] MinLength(String, usize), #[error("Field '{0}' contains invalid characters")] InvalidCharacters(String), }

#[derive(Deserialize)] struct CreateUser { name: String, email: String, password: String, }

impl CreateUser { fn validate(&self) -> Result<(), Vec<ValidationError>> { let mut errors = Vec::new();

if self.name.trim().is_empty() { errors.push(ValidationError::RequiredField("name".into())); }

if self.name.len() < 2 { errors.push(ValidationError::MinLength("name".into(), 2)); }

if !self.email.contains('@') { errors.push(ValidationError::InvalidCharacters("email".into())); }

if self.password.len() < 8 { errors.push(ValidationError::MinLength("password".into(), 8)); }

if errors.is_empty() { Ok(()) } else { Err(errors) } } }

async fn create_user( ValidatedJson(payload): ValidatedJson<CreateUser>, ) -> impl IntoResponse { match payload.validate() { Ok(()) => (StatusCode::CREATED, Json(payload)), Err(errors) => ( StatusCode::UNPROCESSABLE_ENTITY, Json(json!({ "error": "Validation failed", "details": errors.iter().map(|e| e.to_string()).collect::<Vec<_>>(), })), ), } } ```

Prevention

  • Always handle extractor rejections with detailed error responses
  • Use Result<Json<T>, JsonRejection> pattern for explicit error handling
  • Create custom extractor wrappers for consistent error formatting
  • Include field-level error details in API responses
  • Use validation libraries like validator with custom extractors
  • Document expected error response format in API documentation