Home/API Design Patterns/Error Responses

Error Responses

Consistent error handling for APIs

intermediate
errorsapiresponses
🎮 Interactive Playground

What are Error Responses?

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.

The Problem

API errors need to be:

  • Consistent: Same format across all endpoints
  • Informative: Help developers debug issues
  • Secure: Don't leak internal details
  • Actionable: Guide users to fix problems

Example Code

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);
}

Why This Works

  1. Consistent structure: All errors have code, message, status
  2. Machine-readable codes: Easy to handle programmatically
  3. Field-level details: Specific validation errors
  4. thiserror integration: Clean error type definitions

HTTP Status Code Guidelines

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

Error Response Examples

// 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
  }
}

⚠️ Anti-patterns

// 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()))

Exercises

  1. Add internationalization (i18n) for error messages
  2. Implement error logging middleware
  3. Create error rate metrics
  4. Add retry hints for transient errors

🎮 Try it Yourself

🎮

Error Responses - Playground

Run this code in the official Rust Playground