iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Rust

Rust 後端入門系列 第 13

Day 13 Axum 錯誤處理:自訂錯誤型別

  • 分享至 

  • xImage
  •  

為什麼需要統一錯誤處理

  • 使用者體驗一致:前端或 API 使用者可以預期固定結構的錯誤,方便顯示或自動化處理。
  • 運維與除錯:統一的錯誤格式(含 request id、錯誤 code、說明)方便搜尋與追蹤。
  • 資安:避免在回應中暴露內部堆疊、資料庫錯誤細節或機敏欄位。
  • 測試與可維護性:當錯誤型別被中心化管理時,新增錯誤情況的處理與回傳容易而一致。

Axum 的錯誤模型與 IntoResponse

  • 在 axum 中,任何可以被 handler 回傳的型別最後都會轉成 HTTP 回應(Response)。IntoResponse trait 決定如何把某個型別轉為 Response。
  • 自訂一個錯誤型別並實作 IntoResponse,可以在 handler 或 extractor 回傳這個型別,框架就會自動把它轉成 HTTP 回應。
  • 也可以把錯誤型別用作 Rejection(extractor 的 Rejection),或把錯誤包在 Result<T, E> 回傳,交由 axum 處理。

設計自訂錯誤型別

設計原則

  • 切分語意清楚的錯誤類別(例如:ValidationError、AuthError、NotFound、Conflict、InternalError)。
  • 每個錯誤對應明確的 HTTP 狀態碼(例如 Validation -> 400,Unauthorized -> 401,Forbidden -> 403,NotFound -> 404,Conflict -> 409,Internal -> 500)。
  • 錯誤訊息分為給 client 的 user-facing message(簡短、可本地化)與給開發者的 internal detail(儲存在日誌,不回傳或只在 debug 模式回傳)。
  • 給錯誤一個 machine-friendly code(例如 err_code: "user_not_found"),方便前端判斷。

建議錯誤結構範例

建議設計成如此的JSON格式

{
	"error": {
	"code": "validation_error",
	"message": "欄位 x 為必填",
	"request_id": "..."
	}
}

實作 IntoResponse

說明

  • IntoResponse 實作應該回傳正確的 StatusCode 與 JSON body。
  • 在 IntoResponse 中不要把 raw error 物件直接序列化(可能包含敏感資料或大量內部細節)。只把安全的欄位放入回應。
  • 在返回 500 或內部錯誤時,只回傳 minimal user-facing message,並把完整錯誤寫入日誌(包含 request id)。

範例程式

自訂錯誤型別與 IntoResponse 實作

Cargo.toml

tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
thiserror = "1.0"
anyhow = { version = "1.0", default-features = false }

main.rs

use axum::{
    http::StatusCode,
    response::{IntoResponse, Response},
    routing::get,
    Json, Router,
};
use serde::Serialize;
use tracing::{error, info};

#[derive(Serialize)]
struct ErrorBody {
    error: ErrorInfo,
}

#[derive(Serialize)]
struct ErrorInfo {
    code: String,
    message: String,
    request_id: Option<String>,
}

// thiserror 會自動為 enum 實作 std::fmt::Display
#[derive(thiserror::Error, Debug)]
pub enum AppError {
    #[error("validation failed: {0}")]
    Validation(String),

    #[error("unauthorized")]
    Unauthorized,

    #[error("forbidden")]
    Forbidden,

    #[error("not found")]
    NotFound,

    #[error("conflict: {0}")]
    Conflict(String),

    #[error("internal error")]
    Internal(#[from] anyhow::Error),
}

impl AppError {
    fn status_code(&self) -> StatusCode {
        match self {
            AppError::Validation(_) => StatusCode::BAD_REQUEST,
            AppError::Unauthorized => StatusCode::UNAUTHORIZED,
            AppError::Forbidden => StatusCode::FORBIDDEN,
            AppError::NotFound => StatusCode::NOT_FOUND,
            AppError::Conflict(_) => StatusCode::CONFLICT,
            AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_code(&self) -> &'static str {
        match self {
            AppError::Validation(_) => "validation_error",
            AppError::Unauthorized => "unauthorized",
            AppError::Forbidden => "forbidden",
            AppError::NotFound => "not_found",
            AppError::Conflict(_) => "conflict",
            AppError::Internal(_) => "internal_error",
        }
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let request_id: Option<String> = None;

        match &self {
            AppError::Internal(e) => {
                error!(error = %e, "internal error");
            }
            other => {
                info!(error = %other.to_string(), "handled error");
            }
        }

        let body = ErrorBody {
            error: ErrorInfo {
                code: self.error_code().to_string(),
                message: match &self {
                    AppError::Internal(_) => "internal server error".to_string(),
                    AppError::Validation(msg) => msg.clone(),
                    _ => self.to_string(),
                },
                request_id,
            },
        };

        let status = self.status_code();
        let json = axum::Json(body);
        (status, json).into_response()
    }
}

use std::net::SocketAddr;
use tracing_subscriber;

#[derive(Serialize)]
struct HelloBody {
    message: &'static str,
}

#[tokio::main]
async fn main() {
    // 初始化 tracing
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        .init();

    let app = Router::new()
        .route("/", get(root_handler))
        .route("/error", get(error_handler));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
	        .await
	        .unwrap();
	
	    axum::serve(listener, app)
	        .await
	        .unwrap();
}

async fn root_handler() -> Json<HelloBody> {
    Json(HelloBody {
        message: "Hello, world!",
    })
}

async fn error_handler() -> Result<Json<HelloBody>, AppError> {
    Err(AppError::Validation("invalid input example".to_string()))
}
  • GET / 回傳 HelloBody
  • GET /error 回傳一個 Err(AppError::Validation(...)),示範錯誤處理流程

上一篇
Day 12 Axum 狀態管理與資料共享
系列文
Rust 後端入門13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言