Rust in Everything
基于 axum-core 0.5.6

(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 时才改状态码。

💬 关联讨论

发起讨论
加载中...