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> | 200 | text/plain; charset=utf-8 | axum-core |
Bytes、BytesMut、Vec<u8>、&'static [u8]、[u8; N] 等 | 200 | application/octet-stream | axum-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;序列化失败 500 | application/json;失败时 text/plain; charset=utf-8 | axum |
Html<T> | 看 T | text/html; charset=utf-8 | axum |
Form<T> | 200;序列化失败 500 | application/x-www-form-urlencoded | axum |
NoContent | 204 | 无 | 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 让「加头」可以失败:比如头名不合法时,错误本身就是一个可以直接返回的响应。
现成的实现:
| 类型 | 做什么 |
|---|---|
HeaderMap | headers_mut().extend(self):同名的旧值被替换,自身里同名的多个值都保留 |
[(K, V); N] | 逐个 try_into 后 insert:同名覆盖 |
AppendHeaders<I> | 逐个 try_into 后 append:同名追加 |
Extensions | extensions_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 变成响应,再从左到右套用中间各项,最后设状态码。由此得出三条行为:
- 中间各项改的头会覆盖
R自己设的头(Html就靠这一点)。 - 中间各项之间,右边的覆盖左边的(对
insert语义的类型而言)。 - 状态码最后设,会覆盖
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
问: 状态码放中间不行,那 ([("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 与错误类型」一节末尾的冷知识。
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。
(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」都被划掉了。
动脑筋答案
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 发布前被撤回。