build: add api server error normalization layer
This commit is contained in:
@@ -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
3
server-rs/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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、路由与协议装配。
|
||||
|
||||
@@ -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()
|
||||
|
||||
54
server-rs/apps/api-server/src/error_middleware.rs
Normal file
54
server-rs/apps/api-server/src/error_middleware.rs
Normal 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)
|
||||
}
|
||||
73
server-rs/apps/api-server/src/http_error.rs
Normal file
73
server-rs/apps/api-server/src/http_error.rs
Normal 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", "服务器内部错误"),
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod error_middleware;
|
||||
mod http_error;
|
||||
mod logging;
|
||||
mod request_context;
|
||||
mod state;
|
||||
|
||||
Reference in New Issue
Block a user