博客主页:https://tomcat.blog.csdn.net
博主昵称:农民工老王
主要领域:Java、Linux、K8S
期待大家的关注💖点赞👍收藏⭐留言💬
阅读时长:约 15 分钟
目录
- 一、一个真实的故事
- 二、前置知识(可跳过)
- 2.1 HTTP 请求头(Request Header)
- 2.2 HTTP 状态码
- 2.3 超时(Timeout)
- 三、四个 bug 的故事
- Bug 1:把"0"当成"无限等待"——超时的语义陷阱
- 错误现场
- 出错的代码
- 为什么错了
- 根因
- 修复
- 影响范围
- Bug 2:客户端请求头复制引发的 `restricted header name`——`Connection` 头
- 错误现场
- 出错的代码
- 为什么 `Connection` 头让 `HttpClient` 炸了
- 为什么 `HttpURLConnection` 时代没这问题
- 修复
- 进阶做法:抽公共常量
- Bug 3:配置后端业务系统 B 时报 `{302}`——重定向不跟随
- 错误现场
- 出错的代码
- 302 是什么
- 为什么 `HttpURLConnection` 时代没这问题
- 修复
- 为什么"最后还是代理成功"了
- 影响范围
- Bug 4:代理后端业务系统 B 的页面又报 `restricted header name: "connection"`
- 错误现场
- 根因
- 修复
- 四、元教训:默认值差异是迁移的隐形大坑
- 五、给初级工程师的 7 条避坑清单
- 5.1 改完之后,立刻 grep 这 4 个字符串
- 5.2 关于超时:能不设就不设
- 5.3 关于请求头复制:永远用 5 元素 skip-list
- 5.4 关于重定向:分清"代理"和"普通调用"
- 5.5 关于 HTTP 版本:HTTP/1.1 显式指定
- 5.6 关于 SSL 证书:别再用 `setDefaultSSLSocketFactory`
- 5.7 关于测试:必测 5 个真实场景
- 六、结语
- 附录:参考链接
- 附录:本次修复涉及的 4 个改动
一、一个真实的故事
前段时间我把公司内部的一个老项目从HttpURLConnection升级到HttpClient。改完以后:
- ✅ 本地
mvn clean compile通过 - ✅
mvn package -DskipTests通过 - ✅ 测试环境访问
http://localhost/backendA/admin返回 200 - ❌生产环境首次配置后端业务系统 A 失败:报
Invalid duration: PT0S - ❌修复后访问任意 API 失败:报
restricted header name: "connection" - ❌再修复后配置后端业务系统 B 失败:报
{302}状态码错误 - ❌再再修复后代理请求失败:又是
restricted header name: "connection"
四个 bug,每一个都只在生产环境第一次访问特定接口时才暴露。没有一条在编译期被捕获,也没有一条在测试环境触发。
这篇文章想用最通俗的方式,把这四个 bug 讲清楚——重点不是"怎么修",而是为什么 HttpURLConnection 时代没出过这种问题。一句话总结:
HttpURLConnection是"宽容的老好人",HttpClient是"严格的完美主义者"。迁移到 HttpClient 最大的坑不是 API 变了,而是默认值变了。
二、前置知识(可跳过)
如果你是初级工程师,下面三个概念会反复出现,先用 3 分钟过一遍。
2.1 HTTP 请求头(Request Header)
HTTP 请求长这样(简化版):
GET /api/users/123 HTTP/1.1 ← 请求行 Host: api.example.com ← 请求头开始 User-Agent: Mozilla/5.0 Accept: application/json Connection: keep-alive ← 浏览器自动加的 Cookie: session=abc123 ← 空行 ← 请求体(GET 没有)请求头就是附加在请求上的"元数据"——告诉服务器"我是谁、我要什么格式、我要不要保持连接"。客户端和服务器都可以加请求头,而 JDK 内部有一组"不允许用户手动设置"的请求头(叫受限请求头),因为它们由 JDK 自己管理。
2.2 HTTP 状态码
服务器收到请求后会返回一个数字状态码:
| 状态码 | 含义 | 类比 |
|---|---|---|
200 | 成功 | 餐厅上菜了 |
301/302/303 | “换个地址吧” | 餐厅告诉你"我们搬家了,新地址在 Location 头里" |
400 | 你请求格式错了 | 你点菜用外语,服务员听不懂 |
404 | 资源不存在 | 你点了菜单上没有的菜 |
500 | 服务器内部错误 | 厨房着火了 |
2.3 超时(Timeout)
HTTP 请求可能挂死(网络断了、对方服务器卡了)。客户端一般设两个超时保护自己:
- 连接超时(connect timeout):建 TCP 连接最多等多久
- 读超时(read timeout):建立连接后等响应数据最多等多久
一般会设个上限(比如 30 秒),但有时候业务需要"无限等"——比如长轮询、WebSocket、视频流。
三、四个 bug 的故事
Bug 1:把"0"当成"无限等待"——超时的语义陷阱
错误现场
生产环境首次配置后端业务系统 A 时报:
java.lang.Exception: 管理员请求失败 Invalid duration: PT0S at com.example.proxy.util.HttpUtils.sendRequest(HttpUtils.java:737) at com.example.proxy.util.BackendAUtil.getToken(...)出错的代码
// 原 HttpURLConnection 版本(运行多年无 bug)URLConnectionconn=url.openConnection();conn.setReadTimeout(0);// 0 在这里表示"无限等"conn.setConnectTimeout(0);// 0 在这里也表示"无限等"// 迁移到 HttpClient 后的版本HttpRequestrequest=HttpRequest.newBuilder(URI.create(urlString)).timeout(Duration.ZERO)// ← 报错的源头.header("Content-Type","application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofByteArray(postBytes)).build();我天真地以为Duration.ZERO跟setReadTimeout(0)等价,结果client.send()在构造请求阶段就抛了IllegalArgumentException。
为什么错了
来看 JDK 17 官方文档是怎么写的(来源):
Throws:
IllegalArgumentException— if the duration is non-positive.
也就是说:
Duration.ZERO❌ 抛异常Duration.ofSeconds(-1)❌ 也抛异常Duration.ofMillis(0)❌ 还抛异常- 不调用
.timeout()✅ 走默认行为
根因
HttpURLConnection用int 0这个魔法数字表示"无限等"(源自 Unix socket 的语义)。而HttpClient引入java.time.Duration类型后拒绝任何"非正值",要"无限等"请干脆别设。
修复
// 修复:直接删掉 .timeout(Duration.ZERO)HttpRequestrequest=HttpRequest.newBuilder(URI.create(urlString)).header("Content-Type","application/x-www-form-urlencoded").POST(HttpRequest.BodyPublishers.ofByteArray(postBytes)).build();影响范围
我们在项目的工具类与两个代理 Servlet 里一共找到了9 处Duration.ZERO,全部删除。
Bug 2:客户端请求头复制引发的restricted header name——Connection头
错误现场
修复 Bug 1 后,配置后端业务系统 A 终于能进了。但访问https://host/api/rest/services时报:
java.lang.IllegalArgumentException: restricted header name: "connection" at java.net.http/jdk.internal.net.http.HttpRequestBuilderImpl.checkNameAndValue(...) at com.example.proxy.servlet.BackendProxyAServlet.service(BackendProxyAServlet.java:333)出错的代码
代理服务是反向代理,会把"客户端浏览器发来的请求头"原样复制到"转发给后端业务系统的请求"上:
// 复制请求头Enumeration<String>reqhs=request.getHeaderNames();while(reqhs.hasMoreElements()){Stringh=reqhs.nextElement();if(h==null||h.equalsIgnoreCase("host")||h.equalsIgnoreCase("content-length")){continue;}reqBuilder.header(h,request.getHeader(h));// ← 第一次栽在 "connection" 上}为什么Connection头让HttpClient炸了
来看 JDK 文档(来源):
Throws:
IllegalArgumentException— if the header name is not a valid header token, or if the name is one of:connection,content-length,expect,host,upgrade.
HttpClient把这 5 个请求头列为受限请求头(Restricted Headers),显式设置会立刻抛异常:
| 受限请求头 | 原因 |
|---|---|
host | 由URI自动派生 |
content-length | 由BodyPublisher自动计算 |
connection | 控制 keep-alive,由 JDK 底层管理 |
expect | 用于Expect: 100-continue协商,由 JDK 内部处理 |
upgrade | 用于协议升级(如 WebSocket) |
为什么限制?因为这 5 个头是"连接级"语义,JDK 需要自己管理它们才能正确处理 HTTP/2 多路复用、keep-alive、100-continue 等复杂场景。让你手动设置,连接池会乱套。
为什么HttpURLConnection时代没这问题
HttpURLConnection.setRequestProperty("Connection", "...")允许你这么干,运行时悄悄忽略。相当于"宽容的老好人"——你写错了不报错,默默给你兜底。
HttpClient是"严格的完美主义者"——你写了它不喜欢的,立刻报错让你改。
修复
把 skip-list 扩展为包含所有 5 个受限请求头:
// 修复后if(h==null||h.equalsIgnoreCase("host")||h.equalsIgnoreCase("content-length")||h.equalsIgnoreCase("connection")||h.equalsIgnoreCase("expect")||h.equalsIgnoreCase("upgrade")){continue;}注:本项目 WebSocket 走 Jakarta WebSocket API(
@ServerEndpoint),不通过HttpRequest.Builder设置Upgrade,所以把upgrade加进 skip-list 不会影响 WebSocket 功能。
进阶做法:抽公共常量
我后来发现另一个代理 Servlet(用于后端业务系统 B)也复制了客户端头,但 skip-list 里只跳了Expect、host、content-length,漏掉了Connection。结果/backendB/home又报了一次同样的错。
所以最佳做法是把 5 个受限头抽成共享常量:
// HttpClientProvider.java(公共 HttpClient 工厂类)publicstaticfinalSet<String>RESTRICTED_HEADERS=Set.of("host","content-length","connection","expect","upgrade");// 两个代理 Servlet 共用if(h==null||HttpClientProvider.RESTRICTED_HEADERS.contains(h.toLowerCase(Locale.ROOT))){continue;}Bug 3:配置后端业务系统 B 时报{302}——重定向不跟随
错误现场
修复 Bug 2 后再次访问后端业务系统 B 的配置页,又报:
java.lang.Exception: 管理员请求失败 http_error : {302} at com.example.proxy.util.HttpUtils.sendGetRequest(HttpUtils.java:879) at com.example.proxy.util.BackendBUtil.getVersion(BackendBUtil.java:465) at com.example.proxy.util.BackendBUtil.registerBackendB(BackendBUtil.java:231)出错的代码
// HttpUtils.sendGetRequest —— HTTP GET 拿到响应后只认 200HttpResponse<String>response=client.send(request,BodyHandlers.ofString(StandardCharsets.UTF_8));intstatusCode=response.statusCode();if(statusCode==200){returnresponse.body();}thrownewException("http_error : {"+statusCode+"}");// ← 302 就炸这里302 是什么
302 Found是 HTTP 的"重定向"状态码——服务端告诉客户端"你想要的东西搬到Location头里的地址了,请重发请求过去"。常见触发场景:
- 用户用
http://访问,服务端强制跳https:// - 网站换了域名,旧的 URL 跳新的
- 负载均衡器返回"规范 URL"(canonical URL)
- 未登录访问需要鉴权的页面,302 跳登录页
我们的场景:后端业务系统 B 部署在内网 HTTP 上,但代理配置里写的是https://backendB.company.com。代理服务用 HTTP 协议去访问 B,B 强制 302 跳到 HTTPS。
为什么HttpURLConnection时代没这问题
HttpURLConnection.setInstanceFollowRedirects(true)是默认值——HTTP 客户端自动帮你跟随 3xx 重定向,对调用方完全透明。
HttpClient的HttpClientProvider在我们项目里显式设了followRedirects(Redirect.NEVER)(永不跟随),原因是为了让反向代理场景下能拦截并重写Location头(把内网地址改成对外的代理地址)。但HttpUtils.sendGetRequest这种工具类也用了同一个 HttpClient,结果它也不跟随重定向了。
修复
参考已有的BackendAUtil.sendAdminRequest(POST 版本已有手动重定向处理),在HttpUtils.sendGetRequest里加上同样的 302/303 处理:
// 修复后publicstaticStringsendGetRequest(StringurlString,Stringreferer)throwsException{try{HttpResponse<String>response=sendGet(urlString,referer);intstatusCode=response.statusCode();if(statusCode==200){returnresponse.body();}// 处理HTTP重定向(302/303):对齐 HttpURLConnection 默认 setInstanceFollowRedirects(true)if(statusCode==302||statusCode==303){Stringlocation=response.headers().firstValue("Location").orElse(null);if(location!=null){HttpResponse<String>redirected=sendGet(location,referer);if(redirected.statusCode()==200){returnredirected.body();}thrownewException("http_error : {"+redirected.statusCode()+"}");}}thrownewException("http_error : {"+statusCode+"}");}catch(Exceptione){thrownewException("http_error"+e.getMessage());}}// 私有助手:纯发 GET,返回完整 HttpResponse,方便重发privatestaticHttpResponse<String>sendGet(StringurlString,Stringreferer)throwsException{URLurl=newURL(urlString);booleanisHttps="https".equalsIgnoreCase(url.getProtocol());HttpClientclient=HttpClientProvider.get(isHttps);HttpRequest.Builderbuilder=HttpRequest.newBuilder(URI.create(urlString)).GET();if(!Util.isEmpty(referer)){builder.header("Referer",referer);}returnclient.send(builder.build(),HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));}为什么"最后还是代理成功"了
BackendBUtil.getVersion捕获异常后返回"",registerBackendB的版本校验被跳过,所以 B 仍能注册。但版本校验被静默吞了——如果后端业务系统 B 和代理服务大版本不匹配,应该报错阻断配置,反而被这条WARNING掩盖。
影响范围
sendGetRequest有16 个调用点(多个缓存刷新任务、版本查询、健康检查等)。所有这些调用都正确跟随 302,与HttpURLConnection行为一致。
Bug 4:代理后端业务系统 B 的页面又报restricted header name: "connection"
错误现场
访问https://host/backendB/home(后端 B 代理)时报:
java.lang.IllegalArgumentException: restricted header name: "connection" at com.example.proxy.servlet.BackendProxyBServlet.service(BackendProxyBServlet.java:233)根因
这是 Bug 2 的"姊妹篇"——BackendProxyBServlet的 skip-list 在 HttpClient 改造时被改成"跳过Expect、host、content-length",但漏了Connection。Bug 2 修复的是BackendProxyAServlet,没有顺手改 B 端。
为什么先没暴露?后端 B 的代理平时不常被直接访问,且/backendB/home是 SPA 首页,浏览器要带Connection: keep-alive头才会触发——某些内部调用碰巧没经过完整请求头,所以侥幸逃过。
修复
和 Bug 2 一起抽公共常量,参见上面的"进阶做法"小节。
四、元教训:默认值差异是迁移的隐形大坑
把四个 bug 摆一起看:
| 维度 | HttpURLConnection(老好人) | HttpClient(完美主义者) |
|---|---|---|
| 超时设 0 | setReadTimeout(0)= 无限等 | .timeout(Duration.ZERO)= 抛异常 |
| 连接超时设 0 | setConnectTimeout(0)= 无限等 | .connectTimeout(Duration.ZERO)= 抛异常 |
| 设受限请求头 | 运行时悄悄忽略 | 立刻抛IllegalArgumentException |
| 3xx 重定向 | 默认followRedirects=true | 默认Redirect.NEVER |
| HTTPS 证书信任 | setDefaultSSLSocketFactory(全局污染) | 在HttpClient.Builder.sslContext()注入(局部) |
| HTTP/2 多路复用 | 不支持 | JDK 17 默认启用,会改变headers().map()的多值合并行为 |
| 响应体读取 | InputStream手动读 | BodyHandlers.ofString(StandardCharsets.UTF_8)自动解码 |
核心规律:
HttpURLConnection是"permissive with hidden behavior"——你写错它兜底,你偷懒它帮你做。HttpClient是"strict and explicit"——你写错它报错,你偷懒它就当没这回事。
迁移的本质,是把"运行时隐式行为"翻译成"代码显式表达"。每个 bug 都是在某个角落,依赖了"老好人"的兜底。
五、给初级工程师的 7 条避坑清单
读完故事,送你一份可以直接用的 checklist——任何时候你从 HttpURLConnection 迁到 HttpClient,挨个对一遍:
5.1 改完之后,立刻 grep 这 4 个字符串
# 1. 时长是 0 或负数(任何 .timeout/.connectTimeout 调用都该看一眼)grep-rn"Duration.ZERO\|Duration.ofSeconds(0)\|Duration.ofMillis(0)"src/# 2. 设置请求头的地方 —— 看 skip-list 够不够全grep-rn"requestBuilder.header\|HttpRequest\.newBuilder"src/# 3. 期望 200 的地方 —— 看是否处理了 3xxgrep-rn"statusCode() == 200"src/# 4. 跟随重定向的设置点grep-rn"followRedirects\|setInstanceFollowRedirects"src/5.2 关于超时:能不设就不设
// ❌ 错:表达"无限等"HttpRequestrequest=HttpRequest.newBuilder(uri).timeout(Duration.ZERO).build();// ✅ 对:不设,让 HttpClient 用默认HttpRequestrequest=HttpRequest.newBuilder(uri).build();// ✅ 想要有限超时:明确写出.timeout(Duration.ofSeconds(30))判别口诀:在 HttpClient 里,没设 = 用默认 = 通常等于"无限等"。想要有限超时才显式设。
5.3 关于请求头复制:永远用 5 元素 skip-list
不管你复制的是浏览器请求头还是别的请求,都要跳过这 5 个:
privatestaticfinalSet<String>RESTRICTED_HEADERS=Set.of("host","content-length","connection","expect","upgrade");把这个常量抽出来放在HttpClientProvider或HttpUtils这类共用类里,所有代理 Servlet 共用,杜绝漏改。
5.4 关于重定向:分清"代理"和"普通调用"
- 代理场景(Servlet 转发用户请求):用
Redirect.NEVER,自己拦截Location头并改写。 - 普通工具调用(访问别人的 API):用
Redirect.NORMAL(自动跟随 3xx),或者像我上面那样手动处理 302。
不要图省事在HttpClientProvider里把 followRedirects 设成ALWAYS或NEVER,要让调用方按需选择。
5.5 关于 HTTP 版本:HTTP/1.1 显式指定
HttpClient默认 HTTP/2。多路复用会改变headers().map()的多值合并行为,可能影响下游的Location、Set-Cookie处理。代理场景强烈建议显式.version(HTTP_1_1)。
5.6 关于 SSL 证书:别再用setDefaultSSLSocketFactory
HttpURLConnection时代的"全局信任所有证书"是用HttpsURLConnection.setDefaultSSLSocketFactory()实现的——全局污染,所有HttpsURLConnection都受影响。HttpClient时代在HttpClient.Builder.sslContext()注入是局部的,更安全。
5.7 关于测试:必测 5 个真实场景
HttpClient的多数校验都在build()或send()阶段(请求生命周期的早期),单测覆盖不到。集成测试必须覆盖:
- 配置页
POST /proxy/backendA/config(触发getToken+ 版本查询) - 普通
GET /api/rest/services(触发Connection头复制) POST到 HTTP 协议的后端(触发 302 重定向)- WebSocket 升级(如果支持)
- 大文件 / 慢接口(触发超时)
六、结语
写代码多年,最怕的不是写不出能跑的代码,而是写出一份本地能跑、生产跑挂的代码。HttpURLConnection给了我们多年"岁月静好"的错觉——你写错、漏写、超时设成 0,它都默默兜底。等你换成HttpClient,它把所有的兜底都撤掉,每一处偷懒都变成生产事故。
但话说回来,HttpClient这种"严格"才是好的设计。错误越早暴露(最好在build()那一刻就抛),修复成本越低;越晚暴露(生产环境第一次访问),代价越大。
希望读完这篇,下次你做类似迁移时,能少踩几个坑。如果有其他迁移故事想分享,欢迎留言。
附录:参考链接
- JDK 17
HttpRequest.Builder官方文档 —— 每个方法的Throws条款都值得读 - JDK 17
HttpClient官方文档 - JDK 17
HttpResponse官方文档 - RFC 7230 §3.3.2 - Content-Length vs Transfer-Encoding —— 为什么 skip-list 必须包含
content-length - RFC 7231 §6.4 - 3xx Redirection —— 302 语义
附录:本次修复涉及的 4 个改动
出于公司项目保密考虑,commit hash 与具体类名不列出,仅给出每个改动的主题:
- 删除 9 处
.timeout(Duration.ZERO)—— 解决Invalid duration: PT0S - 后端业务系统 A 的代理 Servlet 复制请求头时跳过
connection/expect - 后端业务系统 B 的代理 Servlet 补上 Connection 跳过 + 抽公共
RESTRICTED_HEADERS常量 HttpUtils.sendGetRequest手动跟随 302/303 重定向
TL;DR:HttpURLConnection 是"宽容的老好人",HttpClient 是"严格的完美主义者"。迁移的 4 个坑(
Duration.ZERO、受限请求头、302 重定向、另一端漏改)都源于"默认值变了"。避坑口诀:不设超时 = 无限等,复制请求头必跳 5 个受限头,工具方法手动跟 302/303,代理场景显式Redirect.NEVER+HTTP_1_1。
如需转载,请注明本文的出处:农民工老王的CSDN博客https://blog.csdn.net/monarch91 。