跳转到正文
zeno's blog
返回

Rust Web:服务器错误处理体系

专题: Rust Web

Table of contents

Open Table of contents

全局图

错误来源                        处理方式                      最终结果
─────────────────────────────────────────────────────────────────────
请求格式不合法                  Extractor Rejection          400/401/415
  (无效 JSON, 缺少 header)     → handler 不执行              自动处理

业务逻辑错误                   自定义 AppError              404/409/422
  (用户不存在, 余额不足)        → handler 返回 Err           你来决定状态码

基础设施错误                   anyhow + context             500
  (数据库挂了, Redis 超时)      → ? 自动传播                 记日志,客户端只看到 500

意料之外的 panic               catch_unwind / tower layer   500
  (数组越界, unwrap 失败)       → 不应该发生                  记日志,返回 500

分层设计

┌─────────────────────────────────────────────────────────────────┐
│ 第一层: Extractor Rejection(axum 自动处理)                      │
│                                                                 │
│  请求格式不对 → extractor 返回 Rejection → 4xx                   │
│  你不需要写任何代码                                               │
├─────────────────────────────────────────────────────────────────┤
│ 第二层: 业务错误(你定义 AppError 枚举)                           │
│                                                                 │
│  "用户不存在" "库存不足" "重复操作" → 对应具体的 HTTP 状态码         │
│  客户端根据错误类型决定怎么处理                                    │
├─────────────────────────────────────────────────────────────────┤
│ 第三层: 基础设施错误(anyhow 兜底)                               │
│                                                                 │
│  数据库连接失败、Redis 超时、序列化错误 → 统一 500                  │
│  客户端不需要知道细节,日志里记录完整错误链                         │
├─────────────────────────────────────────────────────────────────┤
│ 第四层: Panic(理论上不该发生)                                    │
│                                                                 │
│  catch_unwind 兜住 → 返回 500 → 不崩整个服务                     │
└─────────────────────────────────────────────────────────────────┘

具体实现

第一层:Extractor Rejection(零代码)

axum 自动处理,你什么都不需要做:

// 客户端发了无效 JSON → 400 Bad Request
// 客户端没带 Content-Type → 415 Unsupported Media Type
// Path 参数不是合法数字 → 400 Bad Request
async fn handler(Path(id): Path<u32>, Json(body): Json<CreateItem>) -> ... {
    // 走到这里时参数一定是合法的
}

第二层:业务错误(AppError 枚举)

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

#[derive(Debug)]
enum AppError {
    // 客户端错误 — 客户端可以根据类型做不同处理
    NotFound(String),
    Conflict(String),
    Forbidden(String),
    BadRequest(String),

    // 服务端错误 — 客户端只知道"服务器出错了"
    Internal(anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        let (status, error_code, message) = match &self {
            AppError::NotFound(msg) =>
                (StatusCode::NOT_FOUND, "NOT_FOUND", msg.clone()),
            AppError::Conflict(msg) =>
                (StatusCode::CONFLICT, "CONFLICT", msg.clone()),
            AppError::Forbidden(msg) =>
                (StatusCode::FORBIDDEN, "FORBIDDEN", msg.clone()),
            AppError::BadRequest(msg) =>
                (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.clone()),
            AppError::Internal(err) => {
                // 内部错误:日志记完整信息,客户端只看到通用消息
                tracing::error!("{:#}", err);
                (StatusCode::INTERNAL_SERVER_ERROR,
                 "INTERNAL_ERROR",
                 "internal server error".to_string())
            }
        };

        (status, Json(json!({
            "error": {
                "code": error_code,
                "message": message,
            }
        }))).into_response()
    }
}

第三层:基础设施错误用 From 接住

use anyhow::Context;

// 数据库错误 → 自动转成 AppError::Internal
impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        match e {
            sqlx::Error::RowNotFound =>
                AppError::NotFound("resource not found".into()),
            other =>
                AppError::Internal(other.into()),
        }
    }
}

// Redis 错误 → 统一 Internal
impl From<redis::RedisError> for AppError {
    fn from(e: redis::RedisError) -> Self {
        AppError::Internal(e.into())
    }
}

也可以用 thiserror 自动生成,但手写 From 能做更细粒度的映射(比如 RowNotFound → 404 而不是 500)。

Handler 里的使用

async fn get_player(
    State(state): State<AppState>,
    Path(player_id): Path<u32>,
) -> Result<Json<Player>, AppError> {
    // ? 自动转换:sqlx::Error::RowNotFound → AppError::NotFound → 404
    let player = sqlx::query_as::<_, Player>("SELECT * FROM players WHERE id = $1")
        .bind(player_id as i32)
        .fetch_one(&state.db)
        .await?;

    Ok(Json(player))
}

async fn create_item(
    State(state): State<AppState>,
    Path(player_id): Path<u32>,
    Json(body): Json<CreateItem>,
) -> Result<(StatusCode, Json<Item>), AppError> {
    // 业务校验:手动返回具体错误
    let player = get_player_or_404(&state.db, player_id).await?;

    if player.inventory_full() {
        return Err(AppError::BadRequest("inventory is full".into()));
    }

    if body.quantity <= 0 {
        return Err(AppError::BadRequest("quantity must be positive".into()));
    }

    // 数据库操作:? 自动把 sqlx::Error 转成 AppError
    let item = sqlx::query_as::<_, Item>(
        "INSERT INTO items (player_id, name, quantity) VALUES ($1, $2, $3) RETURNING *"
    )
        .bind(player_id as i32)
        .bind(&body.name)
        .bind(body.quantity)
        .fetch_one(&state.db)
        .await
        .context("failed to insert item")?;  // context 添加上下文信息到日志

    Ok((StatusCode::CREATED, Json(item)))
}

辅助函数也返回 AppError

async fn get_player_or_404(db: &PgPool, id: u32) -> Result<Player, AppError> {
    sqlx::query_as::<_, Player>("SELECT * FROM players WHERE id = $1")
        .bind(id as i32)
        .fetch_optional(db)
        .await?                    // sqlx::Error → AppError::Internal
        .ok_or_else(|| AppError::NotFound(format!("player {} not found", id)))
}

async fn verify_token(redis: &redis::Client, token: &str) -> Result<u32, AppError> {
    let mut conn = redis.get_multiplexed_async_connection()
        .await
        .context("failed to connect to redis")?;  // redis::Error → anyhow → Internal

    let player_id: Option<u32> = conn.get(format!("session:{}", token)).await?;

    player_id.ok_or(AppError::Forbidden("invalid or expired token".into()))
}

第四层:Panic 兜底(可选)

use tower_http::catch_panic::CatchPanicLayer;

let app = Router::new()
    .route("/players", get(list_players))
    .layer(CatchPanicLayer::new());  // 某个 handler panic 了 → 返回 500,不崩服务

响应格式统一

所有错误都返回统一的 JSON 结构:

// 成功
{
  "id": 1001,
  "name": "AK-47",
  "quantity": 1
}

// 失败
{
  "error": {
    "code": "NOT_FOUND",
    "message": "player 42 not found"
  }
}

客户端判断逻辑:

HTTP 2xx → 解析 body 为业务数据
HTTP 4xx/5xx → 解析 body 为 error 结构,读 code 字段做分支处理

错误处理决策流程

发生了什么?

    ├─ 请求格式不对(JSON 语法错、参数类型错)
    │     → extractor 自动处理,你不管

    ├─ 业务规则不满足(用户不存在、余额不足、无权限)
    │     → return Err(AppError::NotFound/Conflict/Forbidden/BadRequest)
    │     → 带有意义的 error code + message,客户端需要知道

    ├─ 基础设施出问题(数据库超时、Redis 挂了、网络错误)
    │     → ? 自动传播 → From 转成 AppError::Internal
    │     → 日志记完整错误链(含 context)
    │     → 客户端只看到 500 "internal server error"

    └─ 代码 bug(unwrap panic、数组越界)
          → CatchPanicLayer 兜住 → 500
          → 理论上不应该走到这里,修 bug

完整的 AppError 模块

// src/error.rs
use axum::{http::StatusCode, response::{IntoResponse, Json}};
use serde_json::json;

#[derive(Debug)]
pub enum AppError {
    NotFound(String),
    BadRequest(String),
    Conflict(String),
    Forbidden(String),
    Internal(anyhow::Error),
}

impl AppError {
    pub fn not_found(msg: impl Into<String>) -> Self {
        Self::NotFound(msg.into())
    }
    pub fn bad_request(msg: impl Into<String>) -> Self {
        Self::BadRequest(msg.into())
    }
    pub fn forbidden(msg: impl Into<String>) -> Self {
        Self::Forbidden(msg.into())
    }
}

impl IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        let (status, code, message) = match &self {
            Self::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.as_str()),
            Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.as_str()),
            Self::Conflict(msg) => (StatusCode::CONFLICT, "CONFLICT", msg.as_str()),
            Self::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.as_str()),
            Self::Internal(err) => {
                tracing::error!("{:#}", err);
                (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "internal server error")
            }
        };

        (status, Json(json!({
            "error": { "code": code, "message": message }
        }))).into_response()
    }
}

// 基础设施错误自动转换
impl From<sqlx::Error> for AppError {
    fn from(e: sqlx::Error) -> Self {
        match e {
            sqlx::Error::RowNotFound => Self::NotFound("not found".into()),
            other => Self::Internal(other.into()),
        }
    }
}

impl From<redis::RedisError> for AppError {
    fn from(e: redis::RedisError) -> Self {
        Self::Internal(e.into())
    }
}

// 兜底:任何 anyhow::Error → Internal
impl From<anyhow::Error> for AppError {
    fn from(e: anyhow::Error) -> Self {
        Self::Internal(e)
    }
}

Handler 里只需要 ? 和偶尔的 return Err(AppError::not_found(...)),其他全部自动。


分享这篇文章:

上一篇
Rust 基础:anyhow 为什么值得单独使用
下一篇
Rust Web:Tonic gRPC Server 结构指南