RUST Contents

JSON Errors and Problem Details

Standardize JSON error responses with a Problem Details style contract so clients can handle failures consistently. Separate what you log internally from what you expose externally to stay debuggable without leaking implementation details.

On this page

Why error shape is a production API contract

In production, errors are part of your public API. If every endpoint returns a different JSON shape, clients end up with fragile parsing logic and you lose the ability to reason about failures at scale. A stable error contract also improves operations: dashboards can group failures by category, and on-call can quickly understand what happened without reading code.

Production goals at this level

  • Consistency: all handlers return errors in the same JSON shape.
  • Safety: do not leak stack traces, SQL, internal identifiers, or library error strings to clients.
  • Debuggability: log the detailed internal error with context; return a safe message to clients.
  • Stability: error categories remain stable across releases, even if internal implementation changes.

Problem Details style JSON

A practical baseline is inspired by Problem Details (RFC 7807). You do not need full RFC compliance to benefit. A minimal contract:

  • type: stable error identifier (machine-friendly)
  • title: short summary (human-friendly)
  • status: HTTP status code
  • detail: safe description
  • instance: request path or request id

Define the response model

use serde::Serialize;

#[derive(Debug, Serialize)]
pub struct ProblemDetails {
    // r#type is used because type is a Rust keyword
    pub r#type: String,
    pub title: String,
    pub status: u16,
    pub detail: String,
    pub instance: String,
}

Define an API error type

Keep the public API error categories small and stable. Map common cases explicitly. Everything else becomes Internal.

#[derive(Debug)]
pub enum ApiError {
    BadRequest { message: String },
    NotFound { message: String },
    Conflict { message: String },
    Unauthorized { message: String },
    Internal { context: String },
}

Convert errors into Problem Details

Important rule: return safe details to clients, but log the internal context for operations.

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    Json,
};

impl ApiError {
    fn to_problem(&self, instance: &str) -> (StatusCode, ProblemDetails) {
        match self {
            ApiError::BadRequest { message } => {
                let status = StatusCode::BAD_REQUEST;
                (status, ProblemDetails {
                    r#type: "https://errors.example.com/bad-request".to_string(),
                    title: "Bad Request".to_string(),
                    status: status.as_u16(),
                    detail: message.clone(),
                    instance: instance.to_string(),
                })
            }
            ApiError::NotFound { message } => {
                let status = StatusCode::NOT_FOUND;
                (status, ProblemDetails {
                    r#type: "https://errors.example.com/not-found".to_string(),
                    title: "Not Found".to_string(),
                    status: status.as_u16(),
                    detail: message.clone(),
                    instance: instance.to_string(),
                })
            }
            ApiError::Conflict { message } => {
                let status = StatusCode::CONFLICT;
                (status, ProblemDetails {
                    r#type: "https://errors.example.com/conflict".to_string(),
                    title: "Conflict".to_string(),
                    status: status.as_u16(),
                    detail: message.clone(),
                    instance: instance.to_string(),
                })
            }
            ApiError::Unauthorized { message } => {
                let status = StatusCode::UNAUTHORIZED;
                (status, ProblemDetails {
                    r#type: "https://errors.example.com/unauthorized".to_string(),
                    title: "Unauthorized".to_string(),
                    status: status.as_u16(),
                    detail: message.clone(),
                    instance: instance.to_string(),
                })
            }
            ApiError::Internal { context: _ } => {
                let status = StatusCode::INTERNAL_SERVER_ERROR;
                (status, ProblemDetails {
                    r#type: "https://errors.example.com/internal".to_string(),
                    title: "Internal Server Error".to_string(),
                    status: status.as_u16(),
                    detail: "Unexpected error".to_string(),
                    instance: instance.to_string(),
                })
            }
        }
    }
}

IntoResponse implementation

This is where you enforce the separation between internal logging and external responses. The client gets stable JSON. Your logs get the real context.

impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        // In a later lesson, we will set instance to a request id from middleware.
        // For now, path or a placeholder is fine.
        let instance = "/";

        match &self {
            ApiError::Internal { context } => {
                tracing::error!(context = %context, "internal error");
            }
            other => {
                tracing::warn!(error = ?other, "request failed");
            }
        }

        let (status, body) = self.to_problem(instance);
        (status, Json(body)).into_response()
    }
}

Using ApiError in handlers

Handlers should stay readable. The handler returns Result with ApiError and uses early returns for validation and not found cases.

use axum::{extract::Path, Json};

#[derive(serde::Serialize)]
struct UserDto {
    id: u64,
    name: String,
}

pub async fn get_user(Path(id): Path<u64>) -> Result<Json<UserDto>, ApiError> {
    if id == 0 {
        return Err(ApiError::BadRequest { message: "id must be positive".to_string() });
    }

    // Pretend lookup
    if id != 42 {
        return Err(ApiError::NotFound { message: "user not found".to_string() });
    }

    Ok(Json(UserDto { id, name: "Ada".to_string() }))
}

Status codes: keep the mapping boring

Production APIs benefit from predictable status code semantics:

  • 400: validation and malformed input
  • 401: missing or invalid auth
  • 404: resource not found
  • 409: conflict (duplicate, state mismatch)
  • 500: unexpected failures

Production mindset: stable identifiers and safe details

The type field should be stable across releases. Clients can branch on it reliably. The detail field should be safe to expose. If you need to provide extra debugging info to clients, prefer a request id and let them report it.

Operational checklist

  • Verify every endpoint returns the same JSON error shape.
  • Confirm internal errors are logged with context, but response detail stays generic.
  • Ensure error type identifiers are stable and documented.

What comes next

Next, we will add request id correlation and include it in both logs and the Problem Details instance field. That makes error reports actionable: clients can provide a request id, and you can trace the exact failure quickly.