Consistent error handling for APIs
Consistent error responses help API consumers understand what went wrong and how to fix it. A well-designed error format includes status codes, error codes, messages, and optional details.
API errors need to be:
use axum::{
extract::rejection::{JsonRejection, PathRejection, QueryRejection},
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
/// Standard error response format
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: ErrorBody,
}
#[derive(Debug, Serialize)]
pub struct ErrorBody {
/// Machine-readable error code
pub code: String,
/// Human-readable message
pub message: String,
/// HTTP status code
pub status: u16,
/// Optional field-level errors
#[serde(skip_serializing_if = "Option::is_none")]
pub details: Option<Vec<ErrorDetail>>,
/// Request ID for tracing
#[serde(skip_serializing_if = "Option::is_none")]
pub request_id: Option<String>,
/// Documentation link
#[serde(skip_serializing_if = "Option::is_none")]
pub doc_url: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ErrorDetail {
pub field: String,
pub code: String,
pub message: String,
}
impl ErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>, status: StatusCode) -> Self {
ErrorResponse {
error: ErrorBody {
code: code.into(),
message: message.into(),
status: status.as_u16(),
details: None,
request_id: None,
doc_url: None,
},
}
}
pub fn with_details(mut self, details: Vec<ErrorDetail>) -> Self {
self.error.details = Some(details);
self
}
pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
self.error.request_id = Some(request_id.into());
self
}
pub fn with_doc_url(mut self, doc_url: impl Into<String>) -> Self {
self.error.doc_url = Some(doc_url.into());
self
}
}
/// Application error types
#[derive(Debug, Error)]
pub enum ApiError {
// Client errors (4xx)
#[error("Resource not found: {0}")]
NotFound(String),
#[error("Invalid input: {0}")]
BadRequest(String),
#[error("Validation failed")]
ValidationError(Vec<FieldError>),
#[error("Authentication required")]
Unauthorized,
#[error("Permission denied: {0}")]
Forbidden(String),
#[error("Resource already exists: {0}")]
Conflict(String),
#[error("Rate limit exceeded")]
RateLimited { retry_after: u64 },
#[error("Request body too large")]
PayloadTooLarge,
// Server errors (5xx)
#[error("Internal server error")]
InternalError,
#[error("Service unavailable: {0}")]
ServiceUnavailable(String),
// Extraction errors (from axum)
#[error("Invalid JSON: {0}")]
JsonError(String),
#[error("Invalid path parameter: {0}")]
PathError(String),
#[error("Invalid query parameter: {0}")]
QueryError(String),
}
#[derive(Debug, Clone)]
pub struct FieldError {
pub field: String,
pub code: String,
pub message: String,
}
impl FieldError {
pub fn new(field: impl Into<String>, code: impl Into<String>, message: impl Into<String>) -> Self {
FieldError {
field: field.into(),
code: code.into(),
message: message.into(),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code, message, details) = match &self {
ApiError::NotFound(resource) => (
StatusCode::NOT_FOUND,
"NOT_FOUND",
format!("The requested {} was not found", resource),
None,
),
ApiError::BadRequest(msg) => (
StatusCode::BAD_REQUEST,
"BAD_REQUEST",
msg.clone(),
None,
),
ApiError::ValidationError(errors) => (
StatusCode::UNPROCESSABLE_ENTITY,
"VALIDATION_ERROR",
"One or more fields failed validation".to_string(),
Some(errors.iter().map(|e| ErrorDetail {
field: e.field.clone(),
code: e.code.clone(),
message: e.message.clone(),
}).collect()),
),
ApiError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"Authentication is required to access this resource".to_string(),
None,
),
ApiError::Forbidden(reason) => (
StatusCode::FORBIDDEN,
"FORBIDDEN",
reason.clone(),
None,
),
ApiError::Conflict(resource) => (
StatusCode::CONFLICT,
"CONFLICT",
format!("{} already exists", resource),
None,
),
ApiError::RateLimited { retry_after } => (
StatusCode::TOO_MANY_REQUESTS,
"RATE_LIMITED",
format!("Too many requests. Retry after {} seconds", retry_after),
None,
),
ApiError::PayloadTooLarge => (
StatusCode::PAYLOAD_TOO_LARGE,
"PAYLOAD_TOO_LARGE",
"Request body exceeds maximum size".to_string(),
None,
),
ApiError::InternalError => (
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL_ERROR",
"An unexpected error occurred".to_string(),
None,
),
ApiError::ServiceUnavailable(service) => (
StatusCode::SERVICE_UNAVAILABLE,
"SERVICE_UNAVAILABLE",
format!("{} is temporarily unavailable", service),
None,
),
ApiError::JsonError(msg) => (
StatusCode::BAD_REQUEST,
"INVALID_JSON",
msg.clone(),
None,
),
ApiError::PathError(msg) => (
StatusCode::BAD_REQUEST,
"INVALID_PATH",
msg.clone(),
None,
),
ApiError::QueryError(msg) => (
StatusCode::BAD_REQUEST,
"INVALID_QUERY",
msg.clone(),
None,
),
};
let mut response = ErrorResponse::new(code, message, status);
if let Some(details) = details {
response = response.with_details(details);
}
// Add rate limit header
if let ApiError::RateLimited { retry_after } = &self {
return (
status,
[("Retry-After", retry_after.to_string())],
Json(response),
).into_response();
}
(status, Json(response)).into_response()
}
}
// Convert extraction errors to ApiError
impl From<JsonRejection> for ApiError {
fn from(rejection: JsonRejection) -> Self {
ApiError::JsonError(rejection.body_text())
}
}
impl From<PathRejection> for ApiError {
fn from(rejection: PathRejection) -> Self {
ApiError::PathError(rejection.body_text())
}
}
impl From<QueryRejection> for ApiError {
fn from(rejection: QueryRejection) -> Self {
ApiError::QueryError(rejection.body_text())
}
}
/// Validation helper
pub struct Validator {
errors: Vec<FieldError>,
}
impl Validator {
pub fn new() -> Self {
Validator { errors: Vec::new() }
}
pub fn required(&mut self, field: &str, value: &Option<String>) -> &mut Self {
if value.is_none() || value.as_ref().map(|s| s.is_empty()).unwrap_or(true) {
self.errors.push(FieldError::new(
field,
"REQUIRED",
format!("{} is required", field),
));
}
self
}
pub fn email(&mut self, field: &str, value: &str) -> &mut Self {
if !value.is_empty() && !value.contains('@') {
self.errors.push(FieldError::new(
field,
"INVALID_EMAIL",
"Must be a valid email address",
));
}
self
}
pub fn min_length(&mut self, field: &str, value: &str, min: usize) -> &mut Self {
if value.len() < min {
self.errors.push(FieldError::new(
field,
"TOO_SHORT",
format!("Must be at least {} characters", min),
));
}
self
}
pub fn max_length(&mut self, field: &str, value: &str, max: usize) -> &mut Self {
if value.len() > max {
self.errors.push(FieldError::new(
field,
"TOO_LONG",
format!("Must be at most {} characters", max),
));
}
self
}
pub fn range(&mut self, field: &str, value: i64, min: i64, max: i64) -> &mut Self {
if value < min || value > max {
self.errors.push(FieldError::new(
field,
"OUT_OF_RANGE",
format!("Must be between {} and {}", min, max),
));
}
self
}
pub fn validate(self) -> Result<(), ApiError> {
if self.errors.is_empty() {
Ok(())
} else {
Err(ApiError::ValidationError(self.errors))
}
}
}
impl Default for Validator {
fn default() -> Self {
Self::new()
}
}
/// Example usage in handler
#[derive(Deserialize)]
pub struct CreateUserRequest {
pub email: Option<String>,
pub name: Option<String>,
pub password: Option<String>,
}
pub async fn create_user(
Json(req): Json<CreateUserRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
// Validate input
let email = req.email.clone().unwrap_or_default();
let name = req.name.clone().unwrap_or_default();
let password = req.password.clone().unwrap_or_default();
Validator::new()
.required("email", &req.email)
.email("email", &email)
.required("name", &req.name)
.min_length("name", &name, 2)
.max_length("name", &name, 100)
.required("password", &req.password)
.min_length("password", &password, 8)
.validate()?;
// Create user...
Ok(Json(serde_json::json!({
"id": "123",
"email": email,
"name": name
})))
}
fn main() {
// Example error responses
let not_found = ApiError::NotFound("user".to_string());
println!("Not Found: {:?}", not_found);
let validation_errors = vec![
FieldError::new("email", "INVALID_EMAIL", "Must be valid email"),
FieldError::new("password", "TOO_SHORT", "Must be at least 8 characters"),
];
let validation_error = ApiError::ValidationError(validation_errors);
println!("Validation: {:?}", validation_error);
}
| Code | When to Use |
|------|-------------|
| 400 | Invalid syntax, malformed request |
| 401 | Missing or invalid authentication |
| 403 | Authenticated but not authorized |
| 404 | Resource doesn't exist |
| 409 | Conflict (duplicate, version mismatch) |
| 422 | Valid syntax but semantic errors |
| 429 | Rate limited |
| 500 | Unexpected server error |
| 503 | Service temporarily unavailable |
// 404 Not Found
{
"error": {
"code": "NOT_FOUND",
"message": "The requested user was not found",
"status": 404,
"request_id": "req_abc123"
}
}
// 422 Validation Error
{
"error": {
"code": "VALIDATION_ERROR",
"message": "One or more fields failed validation",
"status": 422,
"details": [
{"field": "email", "code": "INVALID_EMAIL", "message": "Must be valid email"},
{"field": "age", "code": "OUT_OF_RANGE", "message": "Must be between 0 and 150"}
]
}
}
// 429 Rate Limited
{
"error": {
"code": "RATE_LIMITED",
"message": "Too many requests. Retry after 60 seconds",
"status": 429
}
}
// DON'T: Expose internal errors
Err(ApiError::InternalError(format!("{:?}", db_error)))
// DON'T: Generic messages
return Err(ApiError::BadRequest("Error".to_string()));
// DON'T: Wrong status codes
// Using 200 for errors
// Using 500 for validation failures
// DO: Specific, helpful messages
Err(ApiError::BadRequest("Email must be in format user@domain.com".to_string()))
Run this code in the official Rust Playground