故障排查:反向代理转发 GET 请求时 JDK 自动追加 Content-Length:0 致后端 500

博客主页:https://tomcat.blog.csdn.net

博主昵称:农民工老王

主要领域:Java、Linux、K8S

期待大家的关注💖点赞👍收藏⭐留言💬

目录

一、问题现象

一个部署在反向代理(Proxy)之后的后端服务,大部分接口工作正常,但某类"查询自身信息"的接口(下文以 /api/wjatest/self 代称)频繁报错:

  • 绕过代理、直连后端GET http://backend:8081/api/wjatest/self?f=json → 返回 200,正常。
  • 经 Proxy 访问GET http://proxy.example.com/proxy/wjatest/self?f=json → 返回 500 {"message":"Unknown error","status":500}

这个 500 的 body 不是后端标准的 REST 错误格式({"error":{...}}),而是应用层未捕获异常的兜底输出,说明后端侧一定抛了异常,且日志里能查到堆栈。

现象里的两个特征很关键:"大部分接口正常、仅个别接口偶发 500",以及**"直连正常、经代理就挂"**。这从一开始就框定了排查方向------问题大概率出在"代理对请求的加工"上,而不是后端业务逻辑。


二、排查思路:从"差异对比"入手

代理类故障,第一步永远是逐字节对比"代理请求"与"直连请求"到底差了什么

我们把三个请求放在一起比对(浏览器原始请求、Proxy 实际发出的代理请求、直连后端的请求),得到四组差异:

# 差异项 代理请求(失败) 直连请求(成功) 初步判断
1 Host 头 全大写 BACKEND.EXAMPLE.COM:8081 小写 backend.example.com:8081 可疑
2 附加头 多出 X-Forwarded-*X-Proxy-Request-Url 可疑
3 是否带 cookie / referer 不带 中性
4 token 两个不同的 token 另一个 token 对照不严格

此时最大的坑:直连验证用的是"另一个 token",它只能证明"后端健康",不能证明"同一个 token 直连也成功"。所以一开始所有结论都是"疑似",需要闭环验证。


三、关键转折:把怀疑从"业务头"转向"隐藏头"

我们做了几个对照实验(用 Python 脚本直连后端端口,精确控制每个头):

  1. 复刻代理请求的全部显式头 (大写 Host、若干 X-Forwarded-*、cookie、referer)→ 后端返回 200
  2. 经真实代理链路 访问同一接口 → 稳定 500
  3. 经代理注入"补发 X-Proxy-Request-Url / 补发 X-Forwarded-Host / 去掉 cookie / 去掉 referer" → 仍然 500

结论浮现:分水岭不在任何"被日志记录到的头"。所有被我们显式设置、能在审计日志里看到的头,单拆出来都不会触发故障。

那还有谁?------JDK 自动附加、不出现在 request.headers() 里的头

我们转向验证 Content-Length。经验假设:用 HttpRequest.BodyPublishers.noBody() 转发 GET 时,JDK 会补 Content-Length: 0。于是做最小复现:

bash 复制代码
# 直连后端,仅加这两个头,不带任何代理的 X-Forwarded-*:
curl "http://backend:8081/api/wjatest/self?f=json&token=xxx" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Content-Length: 0"
# -> 返回 500  {"message":"Unknown error","status":500}

再拆变量:

  • Content-Length: 0、无 Content-Type200
  • Content-Type、无 Content-Length: 0200
  • 两者同时出现500

触发条件精确定义为:GET + Content-Length: 0 + Content-Type 三者同时成立

最后用本地回显服务器(请求回显探针,注意:它并非网卡抓包工具 ,只是被动接收并打印客户端真正发出的请求头)抓住 JDK 17 HttpClient 实际发出的报文,实锤:

复制代码
GET /test/path?f=json HTTP/1.1
Content-Length: 0          <-- JDK 自动追加,且不出现在 request.headers()
Host: 127.0.0.1:18099
Accept: */*
Content-Type: application/x-www-form-urlencoded   <-- 原样转发的浏览器头

至此证据链闭合:故障是"JDK 自动加的 Content-Length: 0" + "前端带来的 Content-Type"组合所致,与域名、Host 大小写、cookie、referer、token 绑定全部无关。


四、最小复现(Spring Boot 示例)

为了把问题脱敏、可复现地讲清楚,下面用 Spring Boot 写两个最小应用:后端 (被代理服务,含缺陷接口)和代理 (用 HttpClient 转发)。所有 URI 均为自拟示例。

4.1 模拟后端(被代理的服务)

对应真实场景里被代理的后端服务。这里在 /api/wjatest/self 上复现"GET 同时携带 Content-Length:0Content-Type 时返回 500"的缺陷。

java 复制代码
package com.example.demoapp;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.Map;

@SpringBootApplication
public class BackendApplication {
    public static void main(String[] args) {
        SpringApplication.run(BackendApplication.class, args);
    }
}

@RestController
@RequestMapping("/api")
class WjatestController {

    @GetMapping("/wjatest/self")
    public ResponseEntity<Map<String, Object>> wjatestSelf(HttpServletRequest request) {
        String contentLength = request.getHeader("Content-Length");
        String contentType = request.getHeader("Content-Type");

        // 复现后端缺陷:GET 请求同时携带 Content-Length:0 与 Content-Type 时返回 500
        if ("0".equals(contentLength) && contentType != null && !contentType.isEmpty()) {
            Map<String, Object> err = new LinkedHashMap<>();
            err.put("message", "Unknown error");
            err.put("status", 500);
            return ResponseEntity.status(500).body(err);
        }

        Map<String, Object> ok = new LinkedHashMap<>();
        ok.put("id", "self");
        ok.put("name", "Demo Backend");
        ok.put("url", "https://demo.example.com");
        return ResponseEntity.ok(ok);
    }
}

4.2 模拟反向代理(用 HttpClient 转发)

对应 Proxy 组件。把 /proxy/** 转发到后端 /api/**。注意代码中修复点已标注 :无实体请求不转发 Content-Type

java 复制代码
package com.example.proxy;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Enumeration;
import java.util.Locale;
import java.util.Set;

@SpringBootApplication
public class ProxyApplication {
    public static void main(String[] args) {
        SpringApplication.run(ProxyApplication.class, args);
    }
}

@RestController
class ProxyController {

    private final HttpClient client = HttpClient.newHttpClient();

    private static final Set<String> HOP_BY_HOP = Set.of(
            "host", "connection", "content-length", "transfer-encoding",
            "keep-alive", "te", "trailer", "upgrade");

    @RequestMapping(value = "/proxy/**", method = {
            org.springframework.web.bind.annotation.RequestMethod.GET,
            org.springframework.web.bind.annotation.RequestMethod.POST,
            org.springframework.web.bind.annotation.RequestMethod.HEAD,
            org.springframework.web.bind.annotation.RequestMethod.DELETE})
    public ResponseEntity<String> proxy(HttpServletRequest request) throws Exception {
        String uri = request.getRequestURI();
        String target = "http://localhost:8081" + uri.replaceFirst("/proxy", "/api");

        String method = request.getMethod();
        boolean hasBody = request.getContentLength() > 0
                && ("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method));

        HttpRequest.BodyPublisher body = hasBody
                ? HttpRequest.BodyPublishers.ofByteArray(readBody(request))
                : HttpRequest.BodyPublishers.noBody(); // 无实体请求:JDK 自动追加 Content-Length:0

        HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(target))
                .method(method, body);

        // ===== 修复点:无实体请求不转发 Content-Type =====
        boolean bodyless = !hasBody;

        Enumeration<String> names = request.getHeaderNames();
        while (names.hasMoreElements()) {
            String h = names.nextElement();
            if (h == null || HOP_BY_HOP.contains(h.toLowerCase(Locale.ROOT))) {
                continue;
            }
            // 无 body 时 Content-Type 无语义;转发它会与 JDK 自动加的 Content-Length:0 组合,触发后端缺陷
            if (bodyless && "content-type".equalsIgnoreCase(h)) {
                continue;
            }
            builder.header(h, request.getHeader(h));
        }

        HttpRequest req = builder.build();
        HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
        return ResponseEntity.status(resp.statusCode()).body(resp.body());
    }

    private byte[] readBody(HttpServletRequest request) throws Exception {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        byte[] buf = new byte[4096];
        int n;
        while ((n = request.getInputStream().read(buf)) != -1) {
            bos.write(buf, 0, n);
        }
        return bos.toByteArray();
    }
}

4.3 验证 JDK 自动追加 Content-Length:0

一行 Java 即可确认(用本地回显服务器(请求回显探针)抓取实际发出报文):

java 复制代码
import com.sun.net.httpserver.*;
import java.net.*;
import java.net.http.*;
import java.util.concurrent.Executors;

public class JdkHeaderProbe {
    public static void main(String[] args) throws Exception {
        var server = HttpServer.create(new InetSocketAddress(18099), 0);
        server.createContext("/", ex -> {
            System.out.println(ex.getRequestMethod() + " " + ex.getRequestURI());
            ex.getRequestHeaders().forEach((k, v) -> System.out.println(k + ": " + v));
            ex.sendResponseHeaders(200, -1);
        });
        server.setExecutor(Executors.newSingleThreadExecutor());
        server.start();

        HttpClient.newHttpClient()
                .send(HttpRequest.newBuilder(URI.create("http://127.0.0.1:18099/test?f=json"))
                                .method("GET", HttpRequest.BodyPublishers.noBody())
                                .header("Content-Type", "application/x-www-form-urlencoded")
                                .build(),
                        HttpResponse.BodyHandlers.discarding());
        server.stop(0);
    }
}

输出第一行就是 Content-Length: 0------这正是"日志里看不到、但真实存在"的元凶。


五、根因分析

把整条链路串起来:

  1. 前端 SPA(如某些低代码页面搭建器)发出的 GET /proxy/wjatest/self 带着 Content-Type: application/x-www-form-urlencoded(XHR 的默认行为)。
  2. Proxy 用 java.net.http.HttpClient 转发,对无实体的 GET 使用 BodyPublishers.noBody()
  3. JDK 自动为这个请求追加 Content-Length: 0(HTTP/1.1 规范允许,但多数客户端不会给 GET 加)。
  4. Content-Type 被原样转发,于是后端收到 GET + Content-Length: 0 + Content-Type 的非常规组合。
  5. 后端在这类"查询自身信息"的接口上解析请求上下文时,对该组合处理不当,抛出未捕获异常 → 500
  6. 由于 Content-Length: 0 是 JDK 底层自动添加、不在 request.headers() 中,审计日志只记录"显式设置的头",所以排查初期完全看不到它,绕了大弯。

为什么只特定接口挂:这类接口会真正去解析/重建请求上下文(公网 URL、token 绑定等),恰好命中缺陷路径;绝大多数接口不读请求体,所以表现正常------这也解释了"大部分接口正常、仅个别接口偶发 500"的现象。


六、修复方案

代理侧(外科手术式,立即修复)

在头转发循环中,无实体请求跳过 Content-TypeContent-Length 是 JDK 受限头无法手工抑制,只能从 Content-Type 侧规避:

java 复制代码
boolean bodyless = !hasBody; // GET/HEAD/DELETE 且无内容
while (names.hasMoreElements()) {
    String h = names.nextElement();
    if (h == null || HOP_BY_HOP.contains(h.toLowerCase(Locale.ROOT))) continue;
    if (bodyless && "content-type".equalsIgnoreCase(h)) continue; // 无 body 的 CT 无语义,剥离以规避 CL:0+CT 组合
    builder.header(h, request.getHeader(h));
}

bodyless 的判定必须与下游 noBody() 的实际使用条件严格对齐:POST/PUT 带实体时 Content-Type 照常转发,不影响上传、表单等请求。

后端侧(建议同步提单)

GET + Content-Length: 0 + Content-Type合法 HTTP 请求,后端应优雅处理而非 500。最小复现脚本(见 4.1/4.3)可直接作为 Bug 复现附件提交给后端团队。


七、延伸讨论:升级 JDK 能否"免改代码"解决?

排查过程中有智能体反馈:JDK 在 17.0.18 之前存在两个相关 Bug------JDK-8283544 与 JDK-8358942,升级到 17.0.18 及之后即可,不需要改代码。 但实测升级后问题依旧。原因在于这两个 Bug 修的是完全不同的 API 写法

Bug 描述 修复的写法 修复版本
JDK-8283544 HttpClient 给 GET 请求加了 Content-Length: 0 .GET() 便捷方法 JDK 19,并回移植到 17.0.18
JDK-8358942 .method("GET", BodyPublishers.noBody()) 仍被加 Content-Length: 0 .method("GET", noBody()) 这种写法 JDK 26

关键差异在于:我们的代理代码用的是 reqBuilder.method(request.getMethod(), publisher),无实体时 publisher = noBody(),即 .method("GET", noBody())------这恰好是 JDK-8358942 的命中路径,而不是 .GET()

证据来自 OpenJDK 官方记录:

  • JDK-8283544 的官方描述明确写着 "Using HttpRequest.newBuilder() to create a simple GET() request",且它的回归测试在改成 .method("GET", BodyPublishers.noBody())仍会复现原问题------这正是 JDK-8358942 被单独提出来的原因。
  • JDK-8358942 的发布说明(JDK-8369981)写明:仅当使用 method 且方法非 POST/PUT、且 BodyPublisher 报告长度为 0 时才不再发送 Content-Length,且该修复的 Fix Version 是 26

换句话说,那位同事说的"17.0.18 修复"对应的是 8283544(.GET() 便捷方法),与本项目代码路径不是一回事 。要把本项目这个写法修掉,得升到 JDK 26,而不是 17.0.18。

本地实测佐证(用本地回显服务器(请求回显探针)抓取 .method("GET", noBody()) 实际发出的报文):

复制代码
# 本地 JDK 17 与 JDK 25 实测:Content-length: [0] 仍然存在
GET /test?f=json HTTP/1.1
Content-type: [application/x-www-form-urlencoded]
Content-length: [0]          <-- 17 和 25 都没修

JDK 25 都还带 Content-length: 0,而 8358942 只在 26 修复,那么 17.0.18(只含 8283544)自然也不会修这个路径------升级到 17.0.18 后仍然 500,完全符合预期。

结论:

  1. 想"只升级 JDK 不改代码",得升到 JDK 26,但把生产代理组件从 17 跳到 26 版本跨度大、回归风险高;
  2. 更重要的是,依赖"JDK 恰好不发 CL:0"很脆弱:一旦换回老 JDK、或容器/网关别处又写入 Content-Length,故障立刻复现;
  3. 因此,第六节落地的代码修复(无实体请求不转发 Content-Type)才是版本无关、最稳妥的解法 :即便 JDK 仍发 Content-Length: 0,只要没有 Content-Type,后端就返回 200(我们已验证"仅 CL:0、无 CT → 200")。它与 JDK 的修复"正交",无论跑在 JDK 17、25 还是 26 都生效。

补充:即便在 JDK 26 上,如果你主动用 builder.header("Content-Length", "0") 显式设置该头,由于 content-length 默认属于受限头,仍需通过系统属性 jdk.httpclient.allowRestrictedHeaders 显式放行------这也侧面说明 JDK 对该头的处理是刻意收敛的。


八、经验总结

  1. 代理类 500,先逐字节对比"代理请求 vs 直连请求",再谈其他。差异即嫌疑。
  2. 日志里的"无"不等于"真的无"Content-Length: 0 由 JDK 自动添加、不出现在 request.headers(),审计日志天然遗漏------这是本次排查最大的盲区。抓包工具 / 本地回显服务器(请求回显探针)是验证"真实线上报文"的利器。
  3. 孤立变量、最小复现 胜过反复猜。把"域名、Host 大小写、cookie、referer、token、各 X-Forwarded-*"逐个拆开,最后锁定到 CL:0 + CT 这一个组合。
  4. 假设要闭环。同事提出的"漏发某自定义头"等假设,用注入实验即可证伪;不闭环的结论不要急着下。
  5. JDK HttpClientnoBody() 会给 GET 加 Content-Length: 0------这是写反向代理/网关时极易踩的坑,建议在团队内沉淀为规范。
  6. 升级 JDK 不是银弹 。同一现象背后可能是不同的底层 Bug(.GET() vs .method(noBody())),务必先确认代码实际使用的 API 写法,再判断是否真的被修复。

九、回显探针:看清 JDK 自动追加的"隐藏头"

排查代理类故障时,最大的盲区往往是**"应用层日志里看不到、却真实存在于线路上的头"**。本文根因 Content-Length: 0 就是典型:它由 JDK 的 HTTP 栈在"把字节写进 socket 之前"自动注入,根本不进入 HttpRequest.headers() 这个 Java API ,因此任何读取 request.headers() 的审计日志都会永久遗漏它。要拿到真相,必须有一个"能读到 socket 上真实字节"的第三方见证者。

9.1 它是什么,不是什么

回显探针(本地请求回显探针)本质上是一个极简的本地 HTTP 服务器 :被测客户端把请求发给它,它把"自己从 socket 上实际读到的请求行 + 请求头"原样打印出来,再回 200。它是 HTTP 应用层的"请求反射器",而不是抓包工具

手段 工作层 看到的内容
本地回显探针(本文) HTTP 应用层 客户端真实发到 socket 的请求行 + 头
Wireshark / tcpdump 网络层 原始 TCP/IP 包
mitmproxy / Fiddler / Charles HTTP 应用层 同回显探针,但带解码、重放等完整功能

它不嗅探网卡、不截获全局流量,只是被动接收并回显;作用等价于 mitmproxy,但轻量到连依赖都不需要------用 JDK 内置的 com.sun.net.httpserver.HttpServer 即可。

9.2 最小实现

完整可运行代码见 4.3 节的 JdkHeaderProbe(含发起请求的 HttpClient 客户端部分)。其核心只有几行------起一个本地回显服务器,把收到的请求头打印出来:

java 复制代码
var server = HttpServer.create(new InetSocketAddress(18099), 0);
server.createContext("/", ex -> {
    System.out.println("=== 服务器实际收到的请求头(含 JDK 自动附加的) ===");
    ex.getRequestHeaders().forEach((k, v) -> System.out.println(k + ": " + v));
    ex.sendResponseHeaders(200, -1);
});
server.start();
// 再用 HttpClient.method("GET", noBody()) 发一个请求给它即可

运行 java JdkHeaderProbe.java(JDK 11+ 支持单文件源码直接运行),输出第一行往往就是:

复制代码
Content-length: [0]          <-- JDK 自动追加,request.headers() 里看不到

9.3 为什么这个 bug 非它不可

本文的转机正是它:审计日志、curl 直连复现都只能证明"CL:0 + CT 组合会触发 500",却无法解释"为什么经代理就必然带上 CL:0"。只有回显探针站在接收方视角,把 JDK 真正写到线路上的 Content-Length: 0 亮出来,才把"JDK 自动追加 → 应用层不可见 → 日志盲区"这条因果链彻底闭合。

经验法则:凡遇到"日志显示没带某头、但行为却像带了"的代理/网关故障,第一时间起一个本地回显探针或 mitmproxy 看真实报文,比反复翻代码快得多。

9.4 请求流转与回显探针的位置

下图对比"真实故障链路"与"取证链路",标出 Content-Length: 0 的注入点,以及回显探针作为"替代接收方"所处的取证位置:
#mermaid-svg-6Vdt1nVAi4h4ecbm{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6Vdt1nVAi4h4ecbm .error-icon{fill:#552222;}#mermaid-svg-6Vdt1nVAi4h4ecbm .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6Vdt1nVAi4h4ecbm .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .marker.cross{stroke:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6Vdt1nVAi4h4ecbm p{margin:0;}#mermaid-svg-6Vdt1nVAi4h4ecbm .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster-label text{fill:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster-label span{color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster-label span p{background-color:transparent;}#mermaid-svg-6Vdt1nVAi4h4ecbm .label text,#mermaid-svg-6Vdt1nVAi4h4ecbm span{fill:#333;color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node rect,#mermaid-svg-6Vdt1nVAi4h4ecbm .node circle,#mermaid-svg-6Vdt1nVAi4h4ecbm .node ellipse,#mermaid-svg-6Vdt1nVAi4h4ecbm .node polygon,#mermaid-svg-6Vdt1nVAi4h4ecbm .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .rough-node .label text,#mermaid-svg-6Vdt1nVAi4h4ecbm .node .label text,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape .label{text-anchor:middle;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .rough-node .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .node .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape .label,#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape .label{text-align:center;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node.clickable{cursor:pointer;}#mermaid-svg-6Vdt1nVAi4h4ecbm .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .arrowheadPath{fill:#333333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6Vdt1nVAi4h4ecbm .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Vdt1nVAi4h4ecbm .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster text{fill:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm .cluster span{color:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6Vdt1nVAi4h4ecbm .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6Vdt1nVAi4h4ecbm rect.text{fill:none;stroke-width:0;}#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape p,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6Vdt1nVAi4h4ecbm .icon-shape .label rect,#mermaid-svg-6Vdt1nVAi4h4ecbm .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Vdt1nVAi4h4ecbm .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6Vdt1nVAi4h4ecbm .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6Vdt1nVAi4h4ecbm :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} JDK 自动追加

Content-Length: 0
真实链路
触发解析缺陷
取证链路
打印真实报文
前端 XHR

GET + Content-Type
Proxy / HttpClient

.method GET, noBody
请求分叉
后端 /api/wjatest/self
返回 500
本地回显探针

echo server
看到 Content-Length: 0

  • Content-Type

一句话:Content-Length: 0 是 JDK 在 Proxy 转发时"偷偷"加上的;想看见它,就让 Proxy 的客户端把请求发到一个回显探针而不是真实后端------探针会如实打印出线路上的全部头。


附:复现步骤速查

bash 复制代码
# 1. 启动后端(8081)与代理(8080)
# 2. 直连后端 ------ 正常
curl "http://localhost:8081/api/wjatest/self?f=json"

# 3. 经代理、且前端带 Content-Type ------ 复现 500
curl "http://localhost:8080/proxy/wjatest/self?f=json" \
  -H "Content-Type: application/x-www-form-urlencoded"

# 4. 直连后端、手动加两个头 ------ 同样复现 500(证明根因在组合,不在代理)
curl "http://localhost:8081/api/wjatest/self?f=json" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "Content-Length: 0"

# 5. 仅去掉 Content-Type(或仅去掉 Content-Length:0)------ 恢复 200

(本文所有 URI、域名、接口名均为示例,已脱敏处理。)


如需转载,请注明本文的出处:农民工老王的CSDN博客https://blog.csdn.net/monarch91

相关推荐
泡海椒1 小时前
jquick-pdf 防止 PDF 内容分页断裂:keepTogether 属性妙用
java·开发语言·pdf
vx_BS813301 小时前
【项目编号:project51381】Spring Boot 婚纱摄影管理系统:套餐预约、在线咨询、支付与客片展示的一站式业务实现
java·spring boot·eclipse·tomcat·mybatis
IT毕设实战小研1 小时前
基于大数据的商场商铺数据分析与可视化的设计与实现
android·java·大数据·django·课程设计
万年咸鱼1 小时前
Java Classpath 详解:从原理到实战
java
Ivanqhz1 小时前
矩阵引擎的数据流模式与 BM1684X 架构
java·服务器·网络·深度学习·神经网络
小蒜学长1 小时前
大学生健康饮食的智慧管理系统(代码+数据库+LW)
java·后端·springboot·大学生·健康饮食
计算机毕设定制辅导-无忧学长1 小时前
《基于SpringBoot的中学教师数字胜任力测评网站的设计与实现》
java·vue.js·spring boot·mysql·中学教师数字胜任力测评网站
智慧物业老杨1 小时前
人机协同的物业服务重构:技术落地路径与系统化思考
java·大数据·人工智能·重构·系统架构
小蒜学长1 小时前
基于Java的论坛数据可视化分析系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·数据可视化·论坛系统