(StatusCode, Json) 序列化失败仍返回你给的状态码
主线位置:请求主线的最后一步,handler 的返回值经
IntoResponse变成Response。元组先让最后一项生成响应,再套用中间各项,最后设状态码;它不知道最后一项生成的是正常响应还是错误响应。
flowchart TB tuple["(201, 头, Json)"] --> body["Json::into_response"] body -->|"序列化失败"| err["500 + 错误文字"] err --> parts["套用中间的头"] parts --> st["状态码改成 201"] st --> client["客户端:201 + 错误文字"]
没有报错,也没有 panic。客户端收到一个 201 Created,body 却是一句 key must be a string,Content-Type 是 text/plain。
复现
serde_json 拒绝序列化键不是字符串的 map(这里键是元组),用它让 Json 失败。四种返回方式加一种绕法:
axum = "=0.8.9"
serde_json = "1"
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use std::collections::BTreeMap;
use axum::body::Body;
use axum::extract::Request;
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use tower::ServiceExt;
// A map with tuple keys: serde_json refuses to serialize it.
fn bad() -> BTreeMap<(u8, u8), u8> {
BTreeMap::from([((1, 2), 3)])
}
async fn plain() -> Json<BTreeMap<(u8, u8), u8>> {
Json(bad())
}
async fn created() -> (StatusCode, Json<BTreeMap<(u8, u8), u8>>) {
(StatusCode::CREATED, Json(bad()))
}
async fn created_with_parts() -> impl IntoResponse {
(
StatusCode::CREATED,
[(header::LOCATION, "/items/7"), (header::SET_COOKIE, "seen=1")],
Json(bad()),
)
}
// Workaround: serialize first, so the error is yours to map.
async fn to_value_first() -> Result<(StatusCode, Json<serde_json::Value>), (StatusCode, String)> {
let value = serde_json::to_value(bad())
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("serialize: {e}")))?;
Ok((StatusCode::CREATED, Json(value)))
}
// Workaround: only override a status that is not already a server error.
fn status_unless_failed(status: StatusCode, body: impl IntoResponse) -> Response {
let mut res = body.into_response();
if !res.status().is_server_error() {
*res.status_mut() = status;
}
res
}
async fn guarded() -> Response {
status_unless_failed(StatusCode::CREATED, Json(bad()))
}
async fn show(app: &Router, uri: &str) {
let res: Response = app.clone().oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();
let status = res.status();
let h = |name| res.headers().get(name).map(|v: &header::HeaderValue| v.to_str().unwrap().to_owned());
let (ct, loc, cookie) = (h(header::CONTENT_TYPE), h(header::LOCATION), h(header::SET_COOKIE));
let body = axum::body::to_bytes(res.into_body(), 1024).await.unwrap();
println!("{uri:<20} {status}");
println!("{:<20} content-type={ct:?} location={loc:?} set-cookie={cookie:?}", "");
println!("{:<20} body={:?}", "", String::from_utf8_lossy(&body));
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let app = Router::new()
.route("/plain", get(plain))
.route("/created", get(created))
.route("/created_with_parts", get(created_with_parts))
.route("/to_value_first", get(to_value_first))
.route("/guarded", get(guarded));
for uri in ["/plain", "/created", "/created_with_parts", "/to_value_first", "/guarded"] {
show(&app, uri).await;
}
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
/plain 500 Internal Server Error
content-type=Some("text/plain; charset=utf-8") location=None set-cookie=None
body="key must be a string"
/created 201 Created
content-type=Some("text/plain; charset=utf-8") location=None set-cookie=None
body="key must be a string"
/created_with_parts 201 Created
content-type=Some("text/plain; charset=utf-8") location=Some("/items/7") set-cookie=Some("seen=1")
body="key must be a string"
/to_value_first 500 Internal Server Error
content-type=Some("text/plain; charset=utf-8") location=None set-cookie=None
body="serialize: key must be a string"
/guarded 500 Internal Server Error
content-type=Some("text/plain; charset=utf-8") location=None set-cookie=None
body="key must be a string"
前三组是这个坑:
/plain:单独返回Json,序列化失败是 500,合理。/created:同一个Json放进(StatusCode::CREATED, ..),状态码变成 201,body 和Content-Type还是错误响应的。/created_with_parts:中间的Location和Set-Cookie也加上了。客户端收到「创建成功,新资源在/items/7」,cookie 也被设上,可资源的表示根本没生成出来。
源码
Json 的 IntoResponse 在序列化失败时,自己用一个元组造错误响应:
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(
header::CONTENT_TYPE,
HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
)],
err.to_string(),
)
.into_response(),
它返回的仍然是一个普通的 Response,和成功时的类型一样,身上没有任何「我是失败产生的」标记。Form 的 IntoResponse 也一样,失败时是 (StatusCode::INTERNAL_SERVER_ERROR, err.to_string())。
外层的元组看不出区别。两元素的 (StatusCode, R):
impl<R> IntoResponse for (StatusCode, R)
where
R: IntoResponse,
{
fn into_response(self) -> Response {
let mut res = self.1.into_response();
*res.status_mut() = self.0;
res
}
}
带中间部件的那些由 impl_into_response! 展开,(StatusCode, T1, .., R) 的实现是:
fn into_response(self) -> Response {
let (status, $($ty),*, res) = self;
let res = res.into_response();
let parts = ResponseParts { res };
$(
let parts = match $ty.into_response_parts(parts) {
Ok(parts) => parts,
Err(err) => {
return err.into_response();
}
};
)*
(status, parts.res).into_response()
}
顺序是:最后一项先生成响应(这一步失败了,得到 500),中间各项依次套上去(Location、Set-Cookie 照加),最后无条件设状态码。元组的这条规则在 IntoResponse 与 IntoResponseParts 里有完整的实验。
这里有个不对称:中间部件失败(比如头的值不合法)时,宏里是 return err.into_response(),提前返回,状态码不会被覆盖;只有最后一项的失败会被盖掉,因为它的失败已经被它自己变成了一个「正常」的响应。
后果不止是客户端看错。状态码在 handler 里就定了,Router::layer 挂的日志、指标中间件看到的也是 201,按 5xx 统计错误率的监控里不会有这一次。
修复的状态
这个问题在 issue #2287 里被报告过,#3603「Fix IntoResponse for tuples overriding error response codes」在 2026-01-18 合进了 main。做法是给失败的响应打一个标记:main 上 Json 的错误分支多了一项 IntoResponseFailed,
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
[(
header::CONTENT_TYPE,
HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
)],
IntoResponseFailed,
err.to_string(),
)
.into_response(),
它是一个 IntoResponseParts,只往响应的 extensions 里插一个值。元组再看到这个扩展,就既不设状态码,也不套用中间各项:
impl<R> IntoResponse for (StatusCode, R)
where
R: IntoResponse,
{
fn into_response(self) -> Response {
let mut res = self.1.into_response();
if res.extensions().get::<IntoResponseFailed>().is_none() {
*res.status_mut() = self.0;
}
res
}
}
还想无条件覆盖的,改用新类型 ForceStatusCode。IntoResponseFailed 的文档写着「This is a behavior introduced with axum 0.9.」
它没有进 0.8.9。#3603 被挑进 0.8.9 的发布分支后又被整个撤回(axum-v0.8.9 的最后几个提交里有 Revert「Fix IntoResponse for tuples overriding error response codes (#3603)」),原因是改动在 axum-core 里,而 axum-core 0.5.6 已经从 main 发布过了;经过见 IntoResponse 与 IntoResponseParts 的冷知识。到本地仓库的 main(86aa6153,2026-10-19)为止,没有任何发布 tag 包含这个提交,main 上 axum 和 axum-core 的版本号还是 0.8.9 和 0.5.6。上面两段 main 的代码是从仓库读出来的,没有编译运行过:main 依赖的 matchit 0.9.2 不在本地 registry 里。
所以在 axum 0.8.9 + axum-core 0.5.6 上,只能自己绕开。
绕法
先序列化,错误自己映射。 /to_value_first:先 serde_json::to_value(...),失败就走你自己的错误分支(这里是 (StatusCode::INTERNAL_SERVER_ERROR, String),实际项目里通常是自定义错误类型,见 IntoResponse 与 IntoResponseParts 的「Result 与错误类型」);成功后放进 Json 的是 serde_json::Value,序列化 Value 不会失败,元组覆盖状态码也就无害了。to_value 和 Json 内部用的 serde_json::to_writer 对同一个值报的是同一句 key must be a string。代价是多一次中间表示的分配;在意的话可以 serde_json::to_vec 成字节,再配 Content-Type: application/json 返回。
只在非 5xx 时改状态码。 /guarded:自己先 into_response(),看到已经是 5xx 就不动。这和 main 上的修复思路一样,只是没有 IntoResponseFailed 标记,只能拿状态码判断;它对任何 IntoResponse 都适用。要带头,就在 status_unless_failed 返回的 Response 上自己加,或者把 (headers, body) 作为 body 参数传进去,这时头仍会加在失败的响应上。
两种绕法都要改每个用 (StatusCode, Json<T>) 的 handler。哪些 T 会失败:键不是字符串(也不是数字、布尔这类 serde_json 能转成字符串的键)的 map,自定义 Serialize 实现或 #[serde(serialize_with)] 函数返回了错误。字段只有字符串、数字、布尔、Vec、字符串键 map 的结构体不会失败,这样的 handler 不受影响。
小结
Json序列化失败时自己生成 500 +text/plain错误文字,但它是个普通Response,没有失败标记。(StatusCode, .., Json)先生成 body,再套中间部件,最后无条件设状态码:客户端看到你给的状态码、错误文字,Location、Set-Cookie也照加。中间部件自己失败时提前返回,不被覆盖。- 日志和指标中间件看到的是被覆盖后的状态码,这次失败不会出现在 5xx 统计里。
- 修复 #3603(
IntoResponseFailed/ForceStatusCode)在 main 上,0.8.9 发布前被撤回,截至 2026-10-19 没有发布版本包含它。 - 现在的绕法:先
serde_json::to_value,或者只在响应不是 5xx 时才改状态码。