Expected request with Content-Type: application/json 与 400 / 422
主线位置:请求主线的末段,
Handler::call跑最后一个参数Json<T>::from_request。提取失败就把 rejection 变成响应直接返回,你的函数不会被调用。
flowchart TB
req["Request"] --> ct{"Content-Type 是 JSON?"}
ct -->|否| r415["415:不是 JSON 类型"]
ct -->|是| buf["读 body"]
buf -->|超上限| r413["413:超上限"]
buf --> de["反序列化"]
de -->|"Syntax、Eof"| r400["400:语法错"]
de -->|Data| r422["422:数据不符"]
de --> ok["调用 handler"]
Json 的判断顺序、+json 后缀、400 和 422 的分界、错误里的字段路径,在 body 提取器 里用 oneshot 逐条试过。这一页看真实客户端:同一个接口,客户端怎么发会得到哪一种,以及怎样让这些拒绝对前端更友好。
复现:真实的客户端发了什么
起一个真正的 axum::serve,用 curl 8.7.1 和 Node v22.23.3 的 fetch 各发几次,服务端用一个 from_fn 中间件打印收到的 Content-Type:
axum = "=0.8.9"
serde = { version = "1", features = ["derive"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net"] }
use std::process::Command;
use axum::{
Json, Router,
extract::Request,
middleware::{self, Next},
response::Response,
routing::post,
};
use serde::Deserialize;
#[derive(Deserialize)]
struct NewUser {
name: String,
}
async fn create(Json(user): Json<NewUser>) -> String {
format!("created {}", user.name)
}
// print what the client actually sent before the extractor sees it
async fn show_content_type(req: Request, next: Next) -> Response {
let ct = req.headers().get("content-type").map(|v| v.to_str().unwrap().to_owned());
println!(" server saw content-type: {ct:?}");
next.run(req).await
}
fn run(label: &str, program: &str, args: &[&str]) {
println!("{label}");
let out = Command::new(program).args(args).output().unwrap();
println!(" client got: {}", String::from_utf8_lossy(&out.stdout).trim_end());
}
#[tokio::main]
async fn main() {
let app = Router::new()
.route("/users", post(create))
.layer(middleware::from_fn(show_content_type));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/users", listener.local_addr().unwrap());
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
// the server runs on the worker threads; the clients block this one
let curl = |label: &str, extra: &[&str]| {
let mut args = vec!["-s", "--noproxy", "*", "-w", " %{http_code}"];
args.extend_from_slice(extra);
args.push(&url);
run(label, "curl", &args);
};
curl("curl -d '{\"name\":\"ann\"}'", &["-d", r#"{"name":"ann"}"#]);
curl("curl --json '{\"name\":\"ann\"}'", &["--json", r#"{"name":"ann"}"#]);
curl("curl -X POST -H 'content-type: application/json'", &["-X", "POST", "-H", "content-type: application/json"]);
curl("curl --json '{\"name\":1}'", &["--json", r#"{"name":1}"#]);
let js = format!(
"fetch('{url}', {{ method: 'POST', body: JSON.stringify({{ name: 'ann' }}) }})\
.then(async r => console.log(await r.text(), r.status))"
);
run("fetch(url, { method: 'POST', body: JSON.stringify(...) })", "node", &["-e", &js]);
}
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同。每段第二行是服务端看到的,第三行是客户端拿到的正文和状态码:
curl -d '{"name":"ann"}'
server saw content-type: Some("application/x-www-form-urlencoded")
client got: Expected request with `Content-Type: application/json` 415
curl --json '{"name":"ann"}'
server saw content-type: Some("application/json")
client got: created ann 200
curl -X POST -H 'content-type: application/json'
server saw content-type: Some("application/json")
client got: Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 0 400
curl --json '{"name":1}'
server saw content-type: Some("application/json")
client got: Failed to deserialize the JSON body into the target type: name: invalid type: integer `1`, expected a string at line 1 column 9 422
fetch(url, { method: 'POST', body: JSON.stringify(...) })
server saw content-type: Some("text/plain;charset=UTF-8")
client got: Expected request with `Content-Type: application/json` 415
curl -d:body 确实是 JSON,但 curl 给-d配的类型是application/x-www-form-urlencoded。Json只看头不看内容,415。用--json(它同时设了Content-Type和Accept)或者显式-H 'content-type: application/json'。- 有类型、没有 body:
Content-Type对了,body 是空的。空串不是 JSON,serde_json 在第 1 行第 0 列就遇到了结尾,归类为Eof,400。给「body 可选」的接口发空请求,碰到的就是这一行。 - 字段类型不对:JSON 本身没错,
name要字符串给了数字,422,正文里带着出错的字段路径name。 fetch只传字符串 body:按 Fetch 规范,字符串 body 默认类型是text/plain;charset=UTF-8,JSON.stringify不会改变这一点。要写headers: { 'content-type': 'application/json' }。
前端最常见的两种 415,问题都在客户端,不在 handler。
源码:四种拒绝
Json::from_request 先查头、再读 body、最后反序列化:
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
if !json_content_type(req.headers()) {
return Err(MissingJsonContentType.into());
}
let bytes = Bytes::from_request(req, state).await?;
Self::from_bytes(&bytes)
}
它的 Rejection 是 JsonRejection,一个四选一的枚举。前三个的状态码和正文前半句在 rejection.rs 里用 define_rejection! 写死:
| 变体 | 状态码 | 正文 | 什么时候 |
|---|---|---|---|
MissingJsonContentType | 415 | Expected request with `Content-Type: application/json` | 没有这个头,或者不是 application/json、application/*+json |
JsonSyntaxError | 400 | Failed to parse the request body as JSON: + serde_json 的错误 | Syntax、Eof:不是合法 JSON、提前结束、末尾还有别的字符 |
JsonDataError | 422 | Failed to deserialize the JSON body into the target type: + 路径和错误 | Data:合法 JSON,但缺字段、类型不对、数值越界 |
BytesRejection | 413 或 400 | Failed to buffer the request body: ... | 读 body 时出错,主要是超过上限,见 Failed to buffer the request body |
MissingJsonContentType 这个名字有点误导:头存在、只是值不对(比如上面的 text/plain;charset=UTF-8),也是它。
__define_rejection! 给每个拒绝生成 status()、body_text() 和 IntoResponse,后者是 (status, body_text).into_response(),所以响应总是 text/plain; charset=utf-8。生成响应之前它还调一次 __log_rejection!:开着 tracing feature(默认开)时,往 target axum::rejection 发一条 TRACE 级别的事件,带状态码、正文和拒绝类型。级别是 TRACE,常见的 RUST_LOG=info 下这些 4xx 在服务端日志里一行也没有。axum 的提取器文档(「Logging rejections」一节)给的开法是 RUST_LOG=info,axum::rejection=trace cargo run。
修法:服务端怎么回
客户端的修法上面已经说了。服务端能做的,是让拒绝以前端容易处理的形式出现。默认的纯文本正文对 fetch(...).then(r => r.json()) 不友好,可以把 Json 包一层,换掉它的 Rejection。axum-macros 的 #[derive(FromRequest)] 支持 via 和 rejection 两个参数,axum 仓库的 examples/customize-extractor-error 用的就是这个写法:
axum = { version = "=0.8.9", features = ["macros"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
use axum::{
Router,
body::Body,
extract::{FromRequest, rejection::JsonRejection},
http::{Request, StatusCode},
response::{IntoResponse, Response},
routing::post,
};
use http_body_util::BodyExt;
use serde::Deserialize;
use tower::ServiceExt;
// `axum::Json` underneath, our own rejection on top
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(ApiError))]
struct Json<T>(T);
struct ApiError {
status: StatusCode,
message: String,
}
impl From<JsonRejection> for ApiError {
fn from(rejection: JsonRejection) -> Self {
Self { status: rejection.status(), message: rejection.body_text() }
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let body = serde_json::json!({ "error": self.message });
(self.status, axum::Json(body)).into_response()
}
}
#[derive(Deserialize)]
struct NewUser {
name: String,
}
async fn create(Json(user): Json<NewUser>) -> String {
format!("created {}", user.name)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/users", post(create));
let cases: [(Option<&str>, &str); 4] = [
(Some("application/json"), r#"{"name":"ann"}"#),
(Some("text/plain"), r#"{"name":"ann"}"#),
(Some("application/json"), r#"{"name":"#),
(Some("application/json"), r#"{"name":1}"#),
];
for (ct, body) in cases {
let mut req = Request::post("/users");
if let Some(ct) = ct {
req = req.header("content-type", ct);
}
let res = app.clone().oneshot(req.body(Body::from(body)).unwrap()).await.unwrap();
let status = res.status();
let ctype = res.headers()["content-type"].to_str().unwrap().to_owned();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
println!("{status} [{ctype}] {}", String::from_utf8_lossy(&bytes));
}
}
连跑 3 次输出相同:
200 OK [text/plain; charset=utf-8] created ann
415 Unsupported Media Type [application/json] {"error":"Expected request with `Content-Type: application/json`"}
400 Bad Request [application/json] {"error":"Failed to parse the request body as JSON: name: EOF while parsing a value at line 1 column 8"}
422 Unprocessable Entity [application/json] {"error":"Failed to deserialize the JSON body into the target type: name: invalid type: integer `1`, expected a string at line 1 column 9"}
- 状态码没变:
rejection.status()原样带过来,415 / 400 / 422 的语义保留。 - 正文变成
application/json,前端统一按 JSON 解析。 - handler 只是把参数类型从
axum::Json换成了自己的Json,其余不动。想在ApiError里对不同变体做不同处理(比如 422 时把字段路径单独拿出来),match rejection { JsonRejection::JsonDataError(e) => ..., ... }即可,JsonRejection是#[non_exhaustive],要留一个_分支。
只在个别 handler 里处理,也可以直接把参数写成 Result<Json<T>, JsonRejection>,拒绝作为值交给函数(FromRequestParts 与 FromRequest 的「包一层」)。
Option<Json<T>> 收到一个没有 Content-Type 的 JSON body,会怎样?axum = "=0.8.9"
serde = { version = "1", features = ["derive"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
tower = { version = "=0.5.3", features = ["util"] }
http-body-util = "0.1"
use axum::{Json, Router, body::Body, http::Request, routing::post};
use http_body_util::BodyExt;
use serde::Deserialize;
use tower::ServiceExt;
#[derive(Deserialize)]
struct Patch {
name: String,
}
async fn update(patch: Option<Json<Patch>>) -> String {
match patch {
Some(Json(p)) => format!("rename to {}", p.name),
None => "nothing to change".to_owned(),
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/me", post(update));
let cases: [(&str, Option<&str>, &str); 4] = [
("json", Some("application/json"), r#"{"name":"ann"}"#),
("no content-type", None, r#"{"name":"ann"}"#),
("text/plain", Some("text/plain"), r#"{"name":"ann"}"#),
("json, empty body", Some("application/json"), ""),
];
for (label, ct, body) in cases {
let mut req = Request::post("/me");
if let Some(ct) = ct {
req = req.header("content-type", ct);
}
let res = app.clone().oneshot(req.body(Body::from(body)).unwrap()).await.unwrap();
let status = res.status();
let bytes = res.into_body().collect().await.unwrap().to_bytes();
println!("{label:<17} {status} {}", String::from_utf8_lossy(&bytes));
}
}
四个请求的 body 前三个相同,只有头不同;第四个头对了、body 是空的。哪几个会走到 None 分支?答案在本页末尾。
Option<Json<T>> 能编译,本身就是 0.8 里后补的。
Option<Json<T>> 是为「空 body」加的,判断的却是有没有头0.8.0 换成 OptionalFromRequest 之后,Json 起初没有实现它,Option<Json<T>> 编译不过。#3142「Implement OptionalFromRequest for the Json extractor」补上了这个实现,PR 的动机是:「I want to receive requests with an empty body when using the Json extractor without the request getting rejected.」
合进去的 OptionalFromRequest for Json<T> 看的却不是 body,而是 Content-Type 头:没有这个头就是 None,有头但不是 JSON 是 415,有 JSON 头就照常读 body、照常报错。所以「空 body」只有在客户端同时不发 Content-Type 时才变成 None;带着 application/json 发一个空 body,结果和不用 Option 一样是 400。
提交 ef0b99b6 第一次出现在 tag axum-v0.8.2(那个 tag 的 CHANGELOG 把它记在 0.8.1 下,仓库里没有 0.8.1 的 tag;到 0.8.9 的 CHANGELOG 里它被列在 0.8.3 下)。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
json 200 OK rename to ann
no content-type 200 OK nothing to change
text/plain 415 Unsupported Media Type Expected request with `Content-Type: application/json`
json, empty body 400 Bad Request Failed to parse the request body as JSON: EOF while parsing a value at line 1 column 0
只有第二个是 None。
- no content-type:body 里明明有完整的 JSON,handler 却拿到
None,回了「nothing to change」,状态码 200。客户端忘了设头,请求被当成「没有 body」,修改被静默丢掉,没有任何报错。用Option<Json<T>>的接口,这比 415 更难发现。 - text/plain:有头但不是 JSON,
Option不吞这个错误,照样 415。 - json, empty body:头是 JSON,就要求 body 是 JSON,空串是
Eof,400。
想让「空 body 等于不传」,就别用 Option<Json<T>>:用 Bytes 拿到原始字节,空的时候走默认值,否则再 Json::<T>::from_bytes。
小结
Json<T>先看Content-Type(application/json或+json),不看 body 内容:curl -d和只传字符串的fetch都会得到 415。- JSON 本身写错(含空 body)是 400,结构对不上是 422,正文里有 serde_path_to_error 给的字段路径。
- 拒绝的正文是
text/plain,只在axum::rejectiontarget 的TRACE级别记日志;默认日志配置下服务端看不到这些 4xx。 - 用
#[derive(FromRequest)]加via(axum::Json)、rejection(ApiError)包一层,可以保留状态码、换成 JSON 错误体。 Option<Json<T>>按「有没有Content-Type头」判断None,不按 body 是否为空;没带头的 JSON 会被静默当成None。