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)
|
交付物:[../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` 中间件
|
- [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)
|
交付物:[../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
|
- [ ] 接入当前项目兼容的 response envelope
|
||||||
- [ ] 接入 `x-request-id`
|
- [ ] 接入 `x-request-id`
|
||||||
- [ ] 接入 `x-api-version`
|
- [ ] 接入 `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"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -478,6 +480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ license.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net"] }
|
||||||
tower-http = { version = "0.6", features = ["trace"] }
|
tower-http = { version = "0.6", features = ["trace"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
1. [x] 接入统一日志与 tracing
|
1. [x] 接入统一日志与 tracing
|
||||||
2. [x] 接入 `request_id`
|
2. [x] 接入 `request_id`
|
||||||
3. [ ] 接入统一错误处理中间件
|
3. [x] 接入统一错误处理中间件
|
||||||
4. [ ] 接入 response envelope
|
4. [ ] 接入 response envelope
|
||||||
5. [ ] 接入 `/healthz`
|
5. [ ] 接入 `/healthz`
|
||||||
|
|
||||||
@@ -45,6 +45,12 @@
|
|||||||
2. `request_id` 会统一写入请求 `extensions` 与请求头,供 tracing、错误处理中间件和响应头层复用。
|
2. `request_id` 会统一写入请求 `extensions` 与请求头,供 tracing、错误处理中间件和响应头层复用。
|
||||||
3. 响应头回写 `x-request-id` 仍属于后续独立任务,本阶段只完成请求上下文准备。
|
3. 响应头回写 `x-request-id` 仍属于后续独立任务,本阶段只完成请求上下文准备。
|
||||||
|
|
||||||
|
当前错误处理中间件约定:
|
||||||
|
|
||||||
|
1. 对 Axum 默认产生的空 `4xx / 5xx` 响应,统一归一化为 legacy 兼容 JSON 错误体:`{ error: { code, message, details? } }`。
|
||||||
|
2. 已经带 `content-type` 的业务错误响应不会被覆盖,避免抢走后续 response envelope 的职责。
|
||||||
|
3. 统一错误日志会复用当前请求的 `request_id`,便于后续和响应头、envelope 元信息串联。
|
||||||
|
|
||||||
## 3. 边界约束
|
## 3. 边界约束
|
||||||
|
|
||||||
1. `api-server` 负责 HTTP、SSE、Cookie、Header、路由与协议装配。
|
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 tracing::{info_span, Level};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
error_middleware::normalize_error_response,
|
||||||
request_context::{attach_request_context, resolve_request_id},
|
request_context::{attach_request_context, resolve_request_id},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
};
|
};
|
||||||
@@ -10,6 +11,8 @@ use crate::{
|
|||||||
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
// 统一由这里构造 Axum 路由树,后续再逐项挂接中间件与业务路由。
|
||||||
pub fn build_router(state: AppState) -> Router {
|
pub fn build_router(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
// 错误归一化层放在 tracing 里侧,让 tracing 记录到最终对外返回的状态与错误体形态。
|
||||||
|
.layer(middleware::from_fn(normalize_error_response))
|
||||||
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
|
// 当前阶段先统一挂接 HTTP tracing,后续 request_id、响应头与错误中间件继续在这里扩展。
|
||||||
.layer(
|
.layer(
|
||||||
TraceLayer::new_for_http()
|
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 app;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod error_middleware;
|
||||||
|
mod http_error;
|
||||||
mod logging;
|
mod logging;
|
||||||
mod request_context;
|
mod request_context;
|
||||||
mod state;
|
mod state;
|
||||||
|
|||||||
Reference in New Issue
Block a user