同一个 POST 请求,用 fetch、PowerShell 或 curl 调用都能成功,通过 JDK HttpClient 发出时却得到 400。换成 FastAPI 接口后,响应变成 422,服务端声称请求体不存在。

问题不在 JSON,而在 HTTP 协议协商。

现象

后端使用 Spring RestTemplate

new RestTemplate(new JdkClientHttpRequestFactory());

两组对照实验得到相同结论:

客户端结果
Node fetch200 OK
PowerShell Invoke-RestMethod200 OK
默认 JDK HttpClient400 Invalid HTTP request received.422 Body missing
强制 JDK HTTP/1.1200 OK

唯一关键变量是 HTTP 版本。

根因

JdkClientHttpRequestFactory 默认创建 HttpClient.newHttpClient()。JDK HttpClient 偏好 HTTP/2;访问明文 http:// 地址时,会尝试 h2c Upgrade:

Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: ...
User-Agent: Java-http-client/21

部分 Python HTTP parser 不能正确处理这个请求。它们可能直接返回 400,也可能让应用层看到一个没有 Body 的 POST,最终由 FastAPI 返回 422

这也是为什么检查 JSON、Content-Type 和字段定义都找不到问题:请求在进入应用层之前已经被错误解析。

Spring Framework 官方仓库的 #33275 记录了同类现象。维护者的结论 也是 JDK 默认 h2c 行为与 Python parser 的组合问题,而不是 Spring 序列化错误。

为什么不全局固定 HTTP/1.1

下面的修改可以立即解决问题:

HttpClient client = HttpClient.newBuilder()
    .version(HttpClient.Version.HTTP_1_1)
    .build();

但如果整个外部接口客户端都固定为 HTTP/1.1,HTTPS 请求也无法再通过 ALPN 使用 HTTP/2。它适合临时止血,不适合作为全局策略。

对于已经稳定运行的旧版本,按 URL scheme 维护两套 JDK Client 是更实际的维护方案:http:// 使用 HTTP/1.1,https:// 保留默认的 HTTP/2 协商。

OkHttp 的默认行为

OkHttp 4.12.0 的默认协议列表是:

internal val DEFAULT_PROTOCOLS = immutableListOf(HTTP_2, HTTP_1_1)

源码:OkHttpClient.kt

建立明文连接时,只有显式配置 H2_PRIOR_KNOWLEDGE 才使用 HTTP/2,否则直接选择 HTTP/1.1:

if (route.address.sslSocketFactory == null) {
  if (Protocol.H2_PRIOR_KNOWLEDGE in route.address.protocols) {
    protocol = Protocol.H2_PRIOR_KNOWLEDGE
    startHttp2(pingIntervalMillis)
    return
  }

  protocol = Protocol.HTTP_1_1
  return
}

源码:RealConnection.kt

HTTPS 则在 TLS 握手中通过 ALPN 提供 h2http/1.1,没有协商结果时回退 HTTP/1.1:

val maybeProtocol = Platform.get().getSelectedProtocol(sslSocket)
protocol = if (maybeProtocol != null) {
  Protocol.get(maybeProtocol)
} else {
  Protocol.HTTP_1_1
}

源码:RealConnection.kt

因此它的默认策略是:

http://  -> HTTP/1.1,不主动 h2c Upgrade
https:// -> ALPN 协商 HTTP/2,必要时回退 HTTP/1.1

与当前客户端的行为差异

当前实现并非直接调用 java.net.http.HttpClient,而是经过 RestTemplate。它与直接使用 OkHttpClient 的差异如下:

行为RestTemplate + JDK HttpClientOkHttpClient
Body 编码RestTemplate 的 MessageConverter 负责 JSON、表单和文本转换通过 RequestBodyFormBody 等类型显式构造
非 2xx 响应当前自定义 ResponseErrorHandler 将响应转换为异常返回普通 Response,可通过状态码或 isSuccessful 判断
重定向JDK HttpClient 默认 Redirect.NEVER默认跟随 HTTP 和 HTTPS 重定向

这次问题的本质不是“FastAPI 丢了 Body”,而是不同 HTTP 客户端生成了不同的协议报文。选择客户端时,默认协议策略同样属于应用契约。