博客主页https://tomcat.blog.csdn.net博主昵称农民工老王主要领域Java、Linux、K8S期待大家的关注点赞收藏⭐留言阅读时长约 15 分钟目录一、一个真实的故事二、前置知识可跳过2.1 HTTP 请求头Request Header2.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-list5.4 关于重定向分清代理和普通调用5.5 关于 HTTP 版本HTTP/1.1 显式指定5.6 关于 SSL 证书别再用 setDefaultSSLSocketFactory5.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 HeaderHTTP 请求长这样简化版GET /api/users/123 HTTP/1.1 ← 请求行 Host: api.example.com ← 请求头开始 User-Agent: Mozilla/5.0 Accept: application/json Connection: keep-alive ← 浏览器自动加的 Cookie: sessionabc123 ← 空行 ← 请求体GET 没有请求头就是附加在请求上的元数据——告诉服务器我是谁、我要什么格式、我要不要保持连接。客户端和服务器都可以加请求头而 JDK 内部有一组不允许用户手动设置的请求头叫受限请求头因为它们由 JDK 自己管理。2.2 HTTP 状态码服务器收到请求后会返回一个数字状态码状态码含义类比200成功餐厅上菜了301/302/303“换个地址吧”餐厅告诉你我们搬家了新地址在 Location 头里400你请求格式错了你点菜用外语服务员听不懂404资源不存在你点了菜单上没有的菜500服务器内部错误厨房着火了2.3 超时TimeoutHTTP 请求可能挂死网络断了、对方服务器卡了。客户端一般设两个超时保护自己连接超时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 版本运行多年无 bugURLConnectionconnurl.openConnection();conn.setReadTimeout(0);// 0 在这里表示无限等conn.setConnectTimeout(0);// 0 在这里也表示无限等// 迁移到 HttpClient 后的版本HttpRequestrequestHttpRequest.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)HttpRequestrequestHttpRequest.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)出错的代码代理服务是反向代理会把客户端浏览器发来的请求头原样复制到转发给后端业务系统的请求上// 复制请求头EnumerationStringreqhsrequest.getHeaderNames();while(reqhs.hasMoreElements()){Stringhreqhs.nextElement();if(hnull||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(hnull||h.equalsIgnoreCase(host)||h.equalsIgnoreCase(content-length)||h.equalsIgnoreCase(connection)||h.equalsIgnoreCase(expect)||h.equalsIgnoreCase(upgrade)){continue;}注本项目 WebSocket 走 Jakarta WebSocket APIServerEndpoint不通过HttpRequest.Builder设置Upgrade所以把upgrade加进 skip-list 不会影响 WebSocket 功能。进阶做法抽公共常量我后来发现另一个代理 Servlet用于后端业务系统 B也复制了客户端头但 skip-list 里只跳了Expect、host、content-length漏掉了Connection。结果/backendB/home又报了一次同样的错。所以最佳做法是把 5 个受限头抽成共享常量// HttpClientProvider.java公共 HttpClient 工厂类publicstaticfinalSetStringRESTRICTED_HEADERSSet.of(host,content-length,connection,expect,upgrade);// 两个代理 Servlet 共用if(hnull||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 拿到响应后只认 200HttpResponseStringresponseclient.send(request,BodyHandlers.ofString(StandardCharsets.UTF_8));intstatusCoderesponse.statusCode();if(statusCode200){returnresponse.body();}thrownewException(http_error : {statusCode});// ← 302 就炸这里302 是什么302 Found是 HTTP 的重定向状态码——服务端告诉客户端你想要的东西搬到Location头里的地址了请重发请求过去。常见触发场景用户用http://访问服务端强制跳https://网站换了域名旧的 URL 跳新的负载均衡器返回规范 URLcanonical URL未登录访问需要鉴权的页面302 跳登录页我们的场景后端业务系统 B 部署在内网 HTTP 上但代理配置里写的是https://backendB.company.com。代理服务用 HTTP 协议去访问 BB 强制 302 跳到 HTTPS。为什么HttpURLConnection时代没这问题HttpURLConnection.setInstanceFollowRedirects(true)是默认值——HTTP 客户端自动帮你跟随 3xx 重定向对调用方完全透明。HttpClient的HttpClientProvider在我们项目里显式设了followRedirects(Redirect.NEVER)永不跟随原因是为了让反向代理场景下能拦截并重写Location头把内网地址改成对外的代理地址。但HttpUtils.sendGetRequest这种工具类也用了同一个 HttpClient结果它也不跟随重定向了。修复参考已有的BackendAUtil.sendAdminRequestPOST 版本已有手动重定向处理在HttpUtils.sendGetRequest里加上同样的 302/303 处理// 修复后publicstaticStringsendGetRequest(StringurlString,Stringreferer)throwsException{try{HttpResponseStringresponsesendGet(urlString,referer);intstatusCoderesponse.statusCode();if(statusCode200){returnresponse.body();}// 处理HTTP重定向302/303对齐 HttpURLConnection 默认 setInstanceFollowRedirects(true)if(statusCode302||statusCode303){Stringlocationresponse.headers().firstValue(Location).orElse(null);if(location!null){HttpResponseStringredirectedsendGet(location,referer);if(redirected.statusCode()200){returnredirected.body();}thrownewException(http_error : {redirected.statusCode()});}}thrownewException(http_error : {statusCode});}catch(Exceptione){thrownewException(http_errore.getMessage());}}// 私有助手纯发 GET返回完整 HttpResponse方便重发privatestaticHttpResponseStringsendGet(StringurlString,Stringreferer)throwsException{URLurlnewURL(urlString);booleanisHttpshttps.equalsIgnoreCase(url.getProtocol());HttpClientclientHttpClientProvider.get(isHttps);HttpRequest.BuilderbuilderHttpRequest.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完美主义者超时设 0setReadTimeout(0) 无限等.timeout(Duration.ZERO) 抛异常连接超时设 0setConnectTimeout(0) 无限等.connectTimeout(Duration.ZERO) 抛异常设受限请求头运行时悄悄忽略立刻抛IllegalArgumentException3xx 重定向默认followRedirectstrue默认Redirect.NEVERHTTPS 证书信任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-rnDuration.ZERO\|Duration.ofSeconds(0)\|Duration.ofMillis(0)src/# 2. 设置请求头的地方 —— 看 skip-list 够不够全grep-rnrequestBuilder.header\|HttpRequest\.newBuildersrc/# 3. 期望 200 的地方 —— 看是否处理了 3xxgrep-rnstatusCode() 200src/# 4. 跟随重定向的设置点grep-rnfollowRedirects\|setInstanceFollowRedirectssrc/5.2 关于超时能不设就不设// ❌ 错表达无限等HttpRequestrequestHttpRequest.newBuilder(uri).timeout(Duration.ZERO).build();// ✅ 对不设让 HttpClient 用默认HttpRequestrequestHttpRequest.newBuilder(uri).build();// ✅ 想要有限超时明确写出.timeout(Duration.ofSeconds(30))判别口诀在 HttpClient 里没设 用默认 通常等于无限等。想要有限超时才显式设。5.3 关于请求头复制永远用 5 元素 skip-list不管你复制的是浏览器请求头还是别的请求都要跳过这 5 个privatestaticfinalSetStringRESTRICTED_HEADERSSet.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 证书别再用setDefaultSSLSocketFactoryHttpURLConnection时代的全局信任所有证书是用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 17HttpRequest.Builder官方文档 —— 每个方法的Throws条款都值得读JDK 17HttpClient官方文档JDK 17HttpResponse官方文档RFC 7230 §3.3.2 - Content-Length vs Transfer-Encoding —— 为什么 skip-list 必须包含content-lengthRFC 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;DRHttpURLConnection 是宽容的老好人HttpClient 是严格的完美主义者。迁移的 4 个坑Duration.ZERO、受限请求头、302 重定向、另一端漏改都源于默认值变了。避坑口诀不设超时 无限等复制请求头必跳 5 个受限头工具方法手动跟 302/303代理场景显式Redirect.NEVERHTTP_1_1。如需转载请注明本文的出处农民工老王的CSDN博客https://blog.csdn.net/monarch91 。