From cb069de32e0f8cdd0d5b8c293c44ab8ae56dd715 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 21 Apr 2026 01:24:09 +0800 Subject: [PATCH] build: add api server error normalization layer --- .../01_M0_M2_FOUNDATION_AND_AUTH.md | 3 +- server-rs/Cargo.lock | 3 + server-rs/apps/api-server/Cargo.toml | 2 + server-rs/apps/api-server/README.md | 8 +- server-rs/apps/api-server/src/app.rs | 3 + .../apps/api-server/src/error_middleware.rs | 54 ++++++++++++++ server-rs/apps/api-server/src/http_error.rs | 73 +++++++++++++++++++ server-rs/apps/api-server/src/main.rs | 2 + 8 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 server-rs/apps/api-server/src/error_middleware.rs create mode 100644 server-rs/apps/api-server/src/http_error.rs diff --git a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md index 55de2a71..17fe88d6 100644 --- a/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md +++ b/backend-rewrite-tasklist/01_M0_M2_FOUNDATION_AND_AUTH.md @@ -98,7 +98,8 @@ 交付物:[../server-rs/apps/api-server/src/logging.rs](../server-rs/apps/api-server/src/logging.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs)、[../server-rs/apps/api-server/src/main.rs](../server-rs/apps/api-server/src/main.rs) - [x] 接入 `request_id` 中间件 交付物:[../server-rs/apps/api-server/src/request_context.rs](../server-rs/apps/api-server/src/request_context.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs) -- [ ] 接入统一错误处理中间件 +- [x] 接入统一错误处理中间件 + 交付物:[../server-rs/apps/api-server/src/http_error.rs](../server-rs/apps/api-server/src/http_error.rs)、[../server-rs/apps/api-server/src/error_middleware.rs](../server-rs/apps/api-server/src/error_middleware.rs)、[../server-rs/apps/api-server/src/app.rs](../server-rs/apps/api-server/src/app.rs) - [ ] 接入当前项目兼容的 response envelope - [ ] 接入 `x-request-id` - [ ] 接入 `x-api-version` diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 0e0fef32..d0dc8741 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -22,6 +22,8 @@ name = "api-server" version = "0.1.0" dependencies = [ "axum", + "serde", + "serde_json", "tokio", "tower-http", "tracing", @@ -478,6 +480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] diff --git a/server-rs/apps/api-server/Cargo.toml b/server-rs/apps/api-server/Cargo.toml index 8dda2148..cfadb214 100644 --- a/server-rs/apps/api-server/Cargo.toml +++ b/server-rs/apps/api-server/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true [dependencies] axum = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] } tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" diff --git a/server-rs/apps/api-server/README.md b/server-rs/apps/api-server/README.md index 78427033..1e8e1d57 100644 --- a/server-rs/apps/api-server/README.md +++ b/server-rs/apps/api-server/README.md @@ -29,7 +29,7 @@ 1. [x] 接入统一日志与 tracing 2. [x] 接入 `request_id` -3. [ ] 接入统一错误处理中间件 +3. [x] 接入统一错误处理中间件 4. [ ] 接入 response envelope 5. [ ] 接入 `/healthz` @@ -45,6 +45,12 @@ 2. `request_id` 会统一写入请求 `extensions` 与请求头,供 tracing、错误处理中间件和响应头层复用。 3. 响应头回写 `x-request-id` 仍属于后续独立任务,本阶段只完成请求上下文准备。 +当前错误处理中间件约定: + +1. 对 Axum 默认产生的空 `4xx / 5xx` 响应,统一归一化为 legacy 兼容 JSON 错误体:`{ error: { code, message, details? } }`。 +2. 已经带 `content-type` 的业务错误响应不会被覆盖,避免抢走后续 response envelope 的职责。 +3. 统一错误日志会复用当前请求的 `request_id`,便于后续和响应头、envelope 元信息串联。 + ## 3. 边界约束 1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。 diff --git a/server-rs/apps/api-server/src/app.rs b/server-rs/apps/api-server/src/app.rs index 16351a38..8abffd7e 100644 --- a/server-rs/apps/api-server/src/app.rs +++ b/server-rs/apps/api-server/src/app.rs @@ -3,6 +3,7 @@ use tower_http::trace::{DefaultOnFailure, DefaultOnRequest, DefaultOnResponse, T use tracing::{info_span, Level}; use crate::{ + error_middleware::normalize_error_response, request_context::{attach_request_context, resolve_request_id}, state::AppState, }; @@ -10,6 +11,8 @@ use crate::{ // 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。 pub fn build_router(state: AppState) -> Router { Router::new() + // 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。 + .layer(middleware::from_fn(normalize_error_response)) // 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。 .layer( TraceLayer::new_for_http() diff --git a/server-rs/apps/api-server/src/error_middleware.rs b/server-rs/apps/api-server/src/error_middleware.rs new file mode 100644 index 00000000..0de3b608 --- /dev/null +++ b/server-rs/apps/api-server/src/error_middleware.rs @@ -0,0 +1,54 @@ +use axum::{ + extract::Request, + http::header::CONTENT_TYPE, + middleware::Next, + response::{IntoResponse, Response}, +}; +use tracing::{error, warn}; + +use crate::{ + http_error::AppError, + request_context::resolve_request_id, +}; + +pub async fn normalize_error_response(request: Request, next: Next) -> Response { + let request_id = resolve_request_id(&request); + let response = next.run(request).await; + + if !should_normalize_error_response(&response) { + return response; + } + + let app_error = AppError::from_status(response.status()); + let status = response.status(); + let request_id = request_id.as_deref().unwrap_or("unknown"); + + if status.is_server_error() { + error!( + %request_id, + status = status.as_u16(), + error_code = app_error.code(), + "request failed" + ); + } else { + warn!( + %request_id, + status = status.as_u16(), + error_code = app_error.code(), + "request failed" + ); + } + + app_error.into_response() +} + +fn should_normalize_error_response(response: &Response) -> bool { + let status = response.status(); + + if !(status.is_client_error() || status.is_server_error()) { + return false; + } + + // 当前阶段只兜底框架默认的空错误响应或非 JSON 错误响应,避免覆盖后续业务 handler 主动构造的错误 body。 + !response.headers().contains_key(CONTENT_TYPE) +} diff --git a/server-rs/apps/api-server/src/http_error.rs b/server-rs/apps/api-server/src/http_error.rs new file mode 100644 index 00000000..dcb1317e --- /dev/null +++ b/server-rs/apps/api-server/src/http_error.rs @@ -0,0 +1,73 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use serde_json::Value; + +#[derive(Debug)] +pub struct AppError { + status_code: StatusCode, + code: &'static str, + message: &'static str, + details: Option, +} + +#[derive(Debug, Serialize)] +struct LegacyApiErrorBody { + error: LegacyApiErrorPayload, +} + +#[derive(Debug, Serialize)] +struct LegacyApiErrorPayload { + code: &'static str, + message: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +impl AppError { + pub fn from_status(status_code: StatusCode) -> Self { + let (code, message) = resolve_http_error(status_code); + + Self { + status_code, + code, + message, + details: None, + } + } + + pub fn code(&self) -> &'static str { + self.code + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let body = LegacyApiErrorBody { + error: LegacyApiErrorPayload { + code: self.code, + message: self.message, + details: self.details, + }, + }; + + (self.status_code, Json(body)).into_response() + } +} + +fn resolve_http_error(status_code: StatusCode) -> (&'static str, &'static str) { + match status_code { + StatusCode::BAD_REQUEST => ("BAD_REQUEST", "请求参数不合法"), + StatusCode::UNAUTHORIZED => ("UNAUTHORIZED", "未授权访问"), + StatusCode::FORBIDDEN => ("FORBIDDEN", "禁止访问"), + StatusCode::NOT_FOUND => ("NOT_FOUND", "资源不存在"), + StatusCode::CONFLICT => ("CONFLICT", "请求冲突"), + StatusCode::TOO_MANY_REQUESTS => ("TOO_MANY_REQUESTS", "请求过于频繁"), + StatusCode::BAD_GATEWAY => ("UPSTREAM_ERROR", "上游服务请求失败"), + _ if status_code.is_client_error() => ("BAD_REQUEST", "请求参数不合法"), + _ => ("INTERNAL_SERVER_ERROR", "服务器内部错误"), + } +} diff --git a/server-rs/apps/api-server/src/main.rs b/server-rs/apps/api-server/src/main.rs index 931dc478..40713eb7 100644 --- a/server-rs/apps/api-server/src/main.rs +++ b/server-rs/apps/api-server/src/main.rs @@ -1,5 +1,7 @@ mod app; mod config; +mod error_middleware; +mod http_error; mod logging; mod request_context; mod state;