build: add api server error normalization layer

This commit is contained in:
2026-04-21 01:24:09 +08:00
parent 0ac5606a41
commit cb069de32e
8 changed files with 146 additions and 2 deletions

View File

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

3
server-rs/Cargo.lock generated
View File

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

View File

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

View File

@@ -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、路由与协议装配。

View File

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

View File

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

View File

@@ -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<Value>,
}
#[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<Value>,
}
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", "服务器内部错误"),
}
}

View File

@@ -1,5 +1,7 @@
mod app;
mod config;
mod error_middleware;
mod http_error;
mod logging;
mod request_context;
mod state;