Rust in Everything
基于 axum-core 0.5.6

IntoResponse 与 IntoResponseParts

主线位置:请求主线的最后一步。你的函数返回之后,Handler::call 对返回值调一次 into_response(),得到 Response<Body> 交回路由层;提取失败时的 rejection 也走同一个 trait(见 body 提取器)。

flowchart TB
  tup["(StatusCode, T1, T2, R)"] --> body["R.into_response()"]
  body --> p1["T1.into_response_parts"]
  p1 -- 出错 --> early["返回 T1 的错误响应"]
  p1 --> p2["T2.into_response_parts"]
  p2 -- 出错 --> early2["返回 T2 的错误响应"]
  p2 --> st["设置状态码"]
  st --> res["Response"]

两个 trait 都在 axum-core 里,axum 只是重新导出,再给自己的类型(Json、Html、Form、Redirect、Sse 等)加上实现。分两个 trait 的原因是:一个完整的响应只能有一个状态码、一个 body,但可以从很多地方叠加头和扩展。IntoResponse 管「整个响应」,IntoResponseParts 只管「往已有的响应上加东西」。

IntoResponse

IntoResponse 只有一个方法:

pub trait IntoResponse {
    /// Create a response.
    #[must_use]
    fn into_response(self) -> Response;
}

Response 是 http::Response<axum_core::body::Body>。返回值必须是这一个具体类型,所以 handler 返回什么都要在这里统一成同一种 body,路由层才能把所有 handler 擦成同一种服务(见 Route 与类型擦除)。

现成的实现大多是「造一个 body,再补一个 Content-Type」:

类型状态码Content-Type来源
()200无axum-core
StatusCode该状态码无axum-core
&'static str、String、Box<str>、Cow<'static, str>200text/plain; charset=utf-8axum-core
Bytes、BytesMut、Vec<u8>、&'static [u8]、[u8; N] 等200application/octet-streamaxum-core
Body、Response<B>原样原样axum-core
HeaderMap、Extensions、[(K, V); N]200无(body 为空)axum-core
Result<T, E>(T、E 都实现 IntoResponse)看分支看分支axum-core
Json<T>200;序列化失败 500application/json;失败时 text/plain; charset=utf-8axum
Html<T>看 Ttext/html; charset=utf-8axum
Form<T>200;序列化失败 500application/x-www-form-urlencodedaxum
NoContent204无axum

字符串类型都先转成 Cow<'static, str>,由 Cow 的实现统一加头;字节类型同理走 Cow<'static, [u8]>,最后都包成 http_body_util::Full。Html 本身就是一个元组响应:

impl<T> IntoResponse for Html<T>
where
    T: IntoResponse,
{
    fn into_response(self) -> Response {
        (
            [(
                header::CONTENT_TYPE,
                HeaderValue::from_static(mime::TEXT_HTML_UTF_8.as_ref()),
            )],
            self.0,
        )
            .into_response()
    }
}

self.0 先生成自己的响应(比如 &str 带着 text/plain),然后数组把 Content-Type 改写成 text/html。这依赖下面要讲的元组顺序。

IntoResponseParts

IntoResponseParts 拿到的是一个 ResponseParts:

pub trait IntoResponseParts {
    /// The type returned in the event of an error.
    ///
    /// This can be used to fallibly convert types into headers or extensions.
    type Error: IntoResponse;

    /// Set parts of the response
    fn into_response_parts(self, res: ResponseParts) -> Result<ResponseParts, Self::Error>;
}

ResponseParts 里面就是一个 Response,但字段是 pub(crate),对外只给 headers、headers_mut、extensions、extensions_mut 四个方法。实现 IntoResponseParts 的类型因此只能改头和扩展,碰不到状态码和 body。axum/src/docs/response.md 的说法是:「This means you cannot accidentally override the status or body as [IntoResponseParts] only allows setting headers and extensions.」

type Error: IntoResponse 让「加头」可以失败:比如头名不合法时,错误本身就是一个可以直接返回的响应。

现成的实现:

类型做什么
HeaderMapheaders_mut().extend(self):同名的旧值被替换,自身里同名的多个值都保留
[(K, V); N]逐个 try_into 后 insert:同名覆盖
AppendHeaders<I>逐个 try_into 后 append:同名追加
Extensionsextensions_mut().extend(self)
Option<T>None 什么都不做
()什么都不做
(T1, ..., T16)按顺序逐个套用,Error = Response

axum 里的 Extension<T> 也实现了它,所以元组中间可以放 Extension(x) 给响应加扩展。

元组的规则

元组响应的实现都在 into_response.rs 的 impl_into_response! 里,由 all_the_tuples_no_last_special_case! 对 T1 到 T1, ..., T16 各展开一次(「no last special case」是相对于 Handler 用的 all_the_tuples!:那里最后一个参数要单独处理,这里的最后一个 R 已经写在宏的模式里了)。每次展开生成四个实现,第一个元素分别是:没有、StatusCode、http::response::Parts、http::Response<()>。以 StatusCode 开头的那个为例:

        impl<R, $($ty,)*> IntoResponse for (StatusCode, $($ty),*, R)
        where
            $( $ty: IntoResponseParts, )*
            R: IntoResponse,
        {
            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()
            }
        }

再加上手写的 (StatusCode, R)、(Parts, R)、(Response<()>, R)、(R,),能编译的形状就是:

  • 最后一个元素实现 IntoResponse;
  • 第一个元素可以是 StatusCode、Parts、Response<()>,也可以是普通的 IntoResponseParts;
  • 中间每个元素都实现 IntoResponseParts,最多 16 个。

执行顺序和书写顺序不一样:先把最后的 R 变成响应,再从左到右套用中间各项,最后设状态码。由此得出三条行为:

  1. 中间各项改的头会覆盖 R 自己设的头(Html 就靠这一点)。
  2. 中间各项之间,右边的覆盖左边的(对 insert 语义的类型而言)。
  3. 状态码最后设,会覆盖 R 自己的状态码,不管 R 是什么。

套用中间某一项失败时,return err.into_response() 直接返回那一项的错误响应,后面的项和状态码都不再处理。

状态码写在中间就不能编译,因为 StatusCode 只实现了 IntoResponse,没有实现 IntoResponseParts:

use axum::{http::StatusCode, response::IntoResponse};

fn created() -> impl IntoResponse {
  ([("x-request-id", "42")], StatusCode::CREATED, "made it")
}

fn main() {
  let _ = created().into_response();
}

rustc 1.99.0 的报错:

error[E0277]: the trait bound `StatusCode: IntoResponseParts` is not satisfied
 --> src/main.rs:3:17
  |
3 | fn created() -> impl IntoResponse {
  |                 ^^^^^^^^^^^^^^^^^ the trait `IntoResponseParts` is not implemented for `StatusCode`
4 |   ([("x-request-id", "42")], StatusCode::CREATED, "made it")
  |   ---------------------------------------------------------- return type was inferred to be `([(&str, &str); 1], StatusCode, &str)` here
  |
  = help: the following other types implement trait `IntoResponseParts`:
            ()
            (T1,)
            (T1, T2)
            (T1, T2, T3)
            (T1, T2, T3, T4)
            (T1, T2, T3, T4, T5)
            (T1, T2, T3, T4, T5, T6)
            (T1, T2, T3, T4, T5, T6, T7)
          and 15 others
  = note: required for `([(&str, &str); 1], StatusCode, &str)` to implement `IntoResponse`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `exp-axum-body-bad` (bin "exp-axum-body-bad") due to 1 previous error
📝NOTE
没有蠢问题

问: 状态码放中间不行,那 ([("x-id", "42")], StatusCode::ACCEPTED) 为什么能编译,而且返回 202?

答: 因为这时 StatusCode 在最后一个位置,充当的是 body R。StatusCode 的 IntoResponse 生成一个空 body、状态码为自身的响应,数组再往上加头。规则只关心每个位置实现了哪个 trait:最后一个要 IntoResponse,中间的要 IntoResponseParts,StatusCode 只满足前者,所以只能出现在最前(作为专门的状态码槽)或最后(作为 body)。

下面把这几条规则和三种加头方式跑一遍:

use axum::{
  Json,
  http::{HeaderMap, StatusCode, header},
  response::{AppendHeaders, IntoResponse, Response},
};
use http_body_util::BodyExt;
use std::collections::HashMap;

async fn show(name: &str, res: Response) {
  let status = res.status();
  let headers: Vec<String> = res
    .headers()
    .iter()
    .map(|(k, v)| format!("{k}: {}", v.to_str().unwrap()))
    .collect();
  let body = res.into_body().collect().await.unwrap().to_bytes();
  println!("{name}\n  {status} | {} | {:?}", headers.join(", "), String::from_utf8_lossy(&body));
}

#[tokio::main]
async fn main() {
  show("1 (StatusCode, [..], body)", (StatusCode::CREATED, [("x-id", "42")], "made").into_response()).await;
  show("2 ([content-type], &str)", ([(header::CONTENT_TYPE, "text/html")], "<b>hi</b>").into_response()).await;
  show("3 ([..], StatusCode)", ([("x-id", "42")], StatusCode::ACCEPTED).into_response()).await;

  let cookies = [(header::SET_COOKIE, "a=1"), (header::SET_COOKIE, "b=2")];
  show("4 [(K, V); 2]", (cookies.clone(), ()).into_response()).await;
  show("5 AppendHeaders", (AppendHeaders(cookies), ()).into_response()).await;

  let mut map = HeaderMap::new();
  map.append(header::SET_COOKIE, "a=1".parse().unwrap());
  map.append(header::SET_COOKIE, "b=2".parse().unwrap());
  map.insert(header::CONTENT_TYPE, "text/html".parse().unwrap());
  show("6 HeaderMap", (map, "<b>hi</b>").into_response()).await;

  show("7 bad header name", (StatusCode::CREATED, [("bad header", "x")], "made").into_response()).await;

  let mut m = HashMap::new();
  m.insert((1, 2), 3);
  show("8 Json alone", Json(m.clone()).into_response()).await;
  show("9 (StatusCode, Json)", (StatusCode::IM_A_TEAPOT, Json(m)).into_response()).await;
}

M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:

1 (StatusCode, [..], body)
  201 Created | content-type: text/plain; charset=utf-8, x-id: 42 | "made"
2 ([content-type], &str)
  200 OK | content-type: text/html | "<b>hi</b>"
3 ([..], StatusCode)
  202 Accepted | x-id: 42 | ""
4 [(K, V); 2]
  200 OK | set-cookie: b=2 | ""
5 AppendHeaders
  200 OK | set-cookie: a=1, set-cookie: b=2 | ""
6 HeaderMap
  200 OK | content-type: text/html, set-cookie: a=1, set-cookie: b=2 | "<b>hi</b>"
7 bad header name
  500 Internal Server Error | content-type: text/plain; charset=utf-8 | "invalid HTTP header name"
8 Json alone
  500 Internal Server Error | content-type: text/plain; charset=utf-8 | "key must be a string"
9 (StatusCode, Json)
  418 I'm a teapot | content-type: text/plain; charset=utf-8 | "key must be a string"
  • 1:"made" 先带着 text/plain 生成,数组加上 x-id,最后设 201。
  • 2:数组的 Content-Type 覆盖了 &str 设的 text/plain。
  • 4 和 5:同样两个 Set-Cookie,数组用 insert,只剩最后一个 b=2;AppendHeaders 用 append,两个都在。要发多个同名头(Set-Cookie、Link、Vary)时用后者。
  • 6:HeaderMap 介于两者之间:它自己里面的两个 Set-Cookie 都保留,而它的 Content-Type 替换了 body 带来的 text/plain。
  • 7:"bad header" 不是合法的头名,数组的 TryIntoHeaderError 转成 500 直接返回。元组前面的 201 没有生效,因为提前返回跳过了设状态码那一步。
  • 8 和 9:见本页后面「Result 与错误类型」一节末尾的冷知识。
❗IMPORTANT
动脑筋:状态码和 Location 听谁的?

Redirect 的 into_response 是 (status_code, [(LOCATION, location)])。下面两个响应的状态码和 Location 各是什么?

use axum::{
  http::{StatusCode, header::LOCATION},
  response::{IntoResponse, Redirect},
};

fn main() {
  let a = (StatusCode::OK, Redirect::to("/next")).into_response();
  let b = ([(LOCATION, "/other")], Redirect::permanent("/next")).into_response();
  println!("a: {} location={:?}", a.status(), a.headers().get_all(LOCATION).iter().collect::<Vec<_>>());
  println!("b: {} location={:?}", b.status(), b.headers().get_all(LOCATION).iter().collect::<Vec<_>>());
}

Result 与错误类型

Result<T, E> 的实现只是分支转发,所以 handler 可以返回 Result<T, E>,只要 T 和 E 都实现 IntoResponse。这带来 axum 里处理错误的惯用写法:定义一个自己的错误类型,为它实现 IntoResponse,在实现里决定状态码和对外的正文,handler 里用 ?。好处是错误到响应的映射集中在一处,内部细节(数据库报错原文)不会漏给客户端。Handler 对返回值的要求只有 IntoResponse,所以 Result<T, AppError> 和别的返回类型没有区别。

不想为每种组合都定义枚举时,axum-core 提供了 axum::response::Result<T>,错误类型默认是 ErrorResponse:

pub type Result<T, E = ErrorResponse> = std::result::Result<T, E>;

impl<T> IntoResponse for Result<T>
where
    T: IntoResponse,
{
    fn into_response(self) -> Response {
        match self {
            Ok(ok) => ok.into_response(),
            Err(err) => err.0,
        }
    }
}

#[derive(Debug)]
#[must_use]
pub struct ErrorResponse(Response);

impl<T> From<T> for ErrorResponse
where
    T: IntoResponse,
{
    fn from(value: T) -> Self {
        Self(value.into_response())
    }
}

任何 IntoResponse 都能经 From 变成 ErrorResponse,? 会自动调 From,所以同一个函数里可以 ? 出不同类型的错误。注意 ErrorResponse 自己没有实现 IntoResponse,Result<T> 单独写了一个实现、直接取出里面的 Response。如果 ErrorResponse 实现了 IntoResponse,那条 impl<T: IntoResponse> From<T> for ErrorResponse 就会和标准库的 impl<T> From<T> for T 重叠,编译不过。

use axum::{
  Router,
  body::Body,
  extract::{Path, Request},
  http::StatusCode,
  response::{IntoResponse, Response},
  routing::get,
};
use http_body_util::BodyExt;
use tower::ServiceExt;

#[allow(dead_code)]
enum AppError {
  NotFound(u32),
  Db(String),
}

impl IntoResponse for AppError {
  fn into_response(self) -> Response {
    match self {
      AppError::NotFound(id) => (StatusCode::NOT_FOUND, format!("user {id} not found")).into_response(),
      AppError::Db(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
    }
  }
}

async fn user(Path(id): Path<u32>) -> Result<String, AppError> {
  match id {
    1 => Ok("ann".to_owned()),
    2 => Err(AppError::Db("connection reset".to_owned())),
    _ => Err(AppError::NotFound(id)),
  }
}

// axum::response::Result<T> = Result<T, ErrorResponse>; any IntoResponse converts via `?`
async fn mixed(Path(id): Path<u32>) -> axum::response::Result<&'static str> {
  if id == 0 {
    Err((StatusCode::BAD_REQUEST, "id must be positive"))?;
  }
  if id > 100 {
    Err(StatusCode::IM_A_TEAPOT)?;
  }
  Ok("fine")
}

#[tokio::main]
async fn main() {
  let app = Router::new().route("/user/{id}", get(user)).route("/mixed/{id}", get(mixed));
  for uri in ["/user/1", "/user/2", "/user/9", "/mixed/0", "/mixed/5", "/mixed/500"] {
    let res = app.clone().oneshot(Request::get(uri).body(Body::empty()).unwrap()).await.unwrap();
    let status = res.status();
    let b = res.into_body().collect().await.unwrap().to_bytes();
    println!("{uri:<11} {status} {:?}", String::from_utf8_lossy(&b));
  }
}

M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:

/user/1     200 OK "ann"
/user/2     500 Internal Server Error "internal error"
/user/9     404 Not Found "user 9 not found"
/mixed/0    400 Bad Request "id must be positive"
/mixed/5    200 OK "fine"
/mixed/500  418 I'm a teapot ""

/user/2 的内部错误 connection reset 没有出现在响应里,AppError::into_response 决定了对外只说 internal error。/mixed/* 里一个 (StatusCode, &str) 和一个裸 StatusCode 都经 ? 变成了 ErrorResponse。

📝NOTE
冷知识:(StatusCode, Json) 会盖掉序列化失败的 500,修复合进了 0.8.9 的分支又被撤回

上面实验的 8 和 9:Json 序列化失败时自己返回 500 和错误文字,可一旦放进 (StatusCode::IM_A_TEAPOT, Json(..)),状态码在最后被改成 418,客户端看到的是「418 + key must be a string」。issue #2287(axum 0.6.20 时提出)报告的就是这个:「Had I just used Json(map) as the return value, then I'd get a 500 Internal Server Error response — very reasonable! However, instead I am getting the originally desired status code but with a text error message」,并认为它「might be a bit of a footgun」。

#3603「Fix IntoResponse for tuples overriding error response codes」(「Closes #2287」)在 2026-01-18 合进 main:Json 等类型失败时给响应带上一个 IntoResponseFailed 扩展,元组看到它就不再设状态码、不再套用中间各项;还要强制覆盖的,改用新类型 ForceStatusCode。这个 PR 给 IntoResponseFailed 写的文档里有一句「This is a behavior introduced with axum 0.9.」它被挑进了 0.8.9 的发布 PR #3699,但这个改动在 axum-core 里,而 axum-core 0.5.6 已经从 main 发布过了(#3596)。#3699 里的说明是「I merged this and immediately realized that axum-core 0.5.6 already exists on crates.io.」和「I've reverted the changes for axum-core in the v0.8.x branch as it seems that axum-core is released from the main branch.」于是 axum-v0.8.9 这个 tag 的最后几个提交就是 Revert「Fix IntoResponse for tuples overriding error response codes (#3603)」和 Revert「axum-core 0.5.6」,#3699 的描述里 #3603 和「Closes #2287」都被划掉了。

结果是:axum 0.8.9 + axum-core 0.5.6 仍然是上面元组代码的行为,状态码无条件覆盖;修复只在 main 上,本地仓库里还没有任何发布 tag 包含它。想让序列化错误保持 500,现在只能先自己序列化、出错时单独返回。

动脑筋答案

M2 Max、rustc 1.99.0、--release,跑 3 次输出相同:

a: 200 OK location=["/next"]
b: 308 Permanent Redirect location=["/other"]
  • a:Redirect::to 先生成 303 + Location: /next,元组最后设状态码,303 被改成 200。浏览器收到 200 不会跳转,Location 白带了。
  • b:Redirect::permanent 生成 308 + Location: /next,然后数组用 insert 把 Location 换成 /other,状态码没人动,还是 308。只有一个 Location,不是两个。

两条都是「body 先生成,后面的覆盖前面的」。

小结

  • IntoResponse 生成整个响应;IntoResponseParts 只能经 ResponseParts 改头和扩展,碰不到状态码和 body。现成实现大多是「造 body + 加 Content-Type」。
  • 元组的形状:第一个可以是 StatusCode / Parts / Response<()> 或普通部件,中间 1 到 16 个 IntoResponseParts,最后一个 IntoResponse。StatusCode 不是 IntoResponseParts,放中间编译不过。
  • 执行顺序:先生成 body,再从左到右套用部件,最后设状态码;部件覆盖 body 的头,状态码覆盖 body 的状态码。部件出错时直接返回它的错误响应。
  • 数组和 HeaderMap 对同名头是替换,AppendHeaders 是追加。
  • 错误处理的惯用法是自定义错误类型并实现 IntoResponse;axum::response::Result<T> 用 ErrorResponse 收拢任意 IntoResponse 错误。
  • (StatusCode, Json) 会盖掉序列化失败的 500:修复 #3603 在 main 上,0.8.9 发布前被撤回。

💬 关联讨论

发起讨论
加载中...