Java常用类库-34-HttpClient请求的超时与连接归还
第二个请求可能还没有连到服务端
目录同步客户端只允许一条连接。第一个请求收到响应头后,没有读取或关闭响应;第二个请求等待一段时间后抛超时。此时服务端可能完全没有见到第二个请求,因为它还在等待连接池提供可用连接。
把所有异常统一记录为“接口响应慢”,会把客户端资源占用与服务端处理时间混在一起。一次请求至少涉及连接租用、连接建立、响应等待、实体读取和资源归还。每段的等待条件不同,配置名称相近也不能合并为一个总超时。
本篇固定 Apache HttpClient 5.6.4,Java 8 API,在 Zulu 8.0.472 和 Corretto 21.0.11 运行。基础实验只访问本机 127.0.0.1,JDK 客户端对照只在 JDK 21 执行。运行说明记录命令与资源边界,不访问外部服务。
日志运行配置见 第 33 篇;本篇的连接实验关闭自动重试,以隔离不同失败阶段。
租用超时首先检查连接占用
实验连接池总数和每路由上限都设为一,连接租用超时为一百五十毫秒。第一条请求使用显式响应对象,响应实体尚未消费时,pool.getTotalStats().getLeased 返回一。此时发起第二条请求,得到 ConnectionRequestTimeoutException。
这个二值观察比只测总耗时更直接:池中连接确实被占用,第二次等待确实进入租用超时异常路径。测试没有把运行时间精确到一百五十毫秒作为判断,因为线程调度和机器负载会影响实际经过时间;参数是等待上限策略,断言重点是等待对象与失败类型。
InternalExecRuntime 固定实现从 RequestConfig 取得 connectionRequestTimeout,调用连接管理器 lease,再等待租用结果。捕获 TimeoutException 后取消租用请求,并转换成 ConnectionRequestTimeoutException。
随后实验读取第一条响应的 data 实体并关闭响应,leased 降为零;再次发起请求得到二百状态,handler 返回后 leased 仍为零。客户端关闭后,池中可用连接数也归零。占用、释放、复用和最终关闭都有单独观察,不以 try 代码块存在就认定资源已经归还。
增加连接池容量可以缓解并发等待,却不能修复遗漏释放。若每个请求都保留连接,再大的池也会逐渐被占满。诊断时先检查响应生命周期、占用数与等待数,再判断容量是否匹配负载;不能直接把租用超时调大当成问题已经解决。
响应等待不是连接建立,也不是总截止时间
慢端点接受请求后通过 latch 等待,不发回响应头。客户端设置 responseTimeout 为一百五十毫秒,测试得到 SocketTimeoutException,并确认服务端已经进入该端点。异常后 leased 为零,说明该次失败没有继续占用池槽位。
这里的服务端进入信号排除了“请求还在等连接”的解释。连接已建立,请求已到达,等待发生在响应阶段。与上一实验相比,相同数量级的超时参数作用于不同阶段,异常类型和服务端可见性也不同。
RequestConfig 固定注释明确说明 responseTimeout 不是总截止时间,自动重新执行等情况可能使总耗时超过它。它还说明该参数在一次消息交换期间覆盖连接管理或 I/O 层的 socket timeout,并提醒多路复用传输可能不支持相同行为。
因此“responseTimeout=一秒”不能直接翻译成“业务调用最多一秒结束”。连接池等待、连接建立、重试、响应体处理与业务解析,都可能贡献额外时间。需要整体预算时,应明确由哪一层计算剩余时间、终止后怎样取消,以及取消后哪些资源必须释放。
连接建立超时属于另一项配置,现代 HttpClient 5 应结合 ConnectionConfig 审查。关闭端口返回连接拒绝,只能证明拒绝路径,不是连接建立超时的等价实验。本章没有构造网络黑洞或宣称实测 connect timeout;这一边界保留为未运行项。
第一条可迁移模式是用等待对象命名超时。等待池槽位、等待 TCP 建立、等待响应数据、等待任务结果,可以分别配置和观测。只有识别了等待阶段,监控分类和调整参数才有明确含义。
响应处理器怎样帮助归还连接
使用 response handler 的 execute 会在处理完成后消费实体并关闭响应。CloseableHttpClient 固定实现用 try-with-resources 管理响应,在 handler 返回后调用 EntityUtils.consume,再返回结果;部分协议异常还包含尝试消费的处理分支。
本实验同时让 handler 主动抛 IllegalArgumentException。调用方得到该异常,之后 leased 为零,说明这条失败路径也退出响应资源范围。测试不是模拟 response.close,而是检查真实连接管理器状态,因此能发现“异常已经抛出但连接仍占用”的问题。
这不意味着 handler 可以返回一个仍依赖响应的 InputStream,再让调用方随意读取。handler 返回时响应生命周期已经结束;需要返回内容,应在受控范围内读取为应用需要的结果,或者选择显式移交流所有权的接口并完整管理关闭责任。
消费实体也可能涉及继续读取网络数据。对巨大或缓慢响应,是否读取全部、是否提前关闭连接以及能否复用,需要结合大小预算与超时设计。示例实体只有四字节,不能据此断言忽略响应体永远没有额外等待或内存成本。
显式响应对象适合需要精细控制读取过程的场景,但它也明确把关闭责任交给调用方。应将取得响应与关闭范围放在一起,并测试正常读取、解析异常和主动终止三条路径。返回一个没有说明所有权的流,会把责任扩散到接口使用者。
错误码、断连和重试分开验收
本机错误端点返回五百零三,handler 正常读取状态码,execute 不因为它是错误状态就自动抛业务异常。测试记录服务端错误端点调用次数为一。HTTP 状态如何映射到业务错误,仍需要应用策略。
断连端点则直接关闭交换,不发送响应头。客户端抛 IOException,服务端断连计数为一。本实验关闭自动重试,所以不会把库自身重试与应用处理混在一起。两者都表示本次业务读取没有成功,但一个具有完整 HTTP 状态,另一个是传输失败,诊断信息不同。
错误端点显式发送 Connection: close,使后续断连实验使用新的连接。这样可以避免把已经失效的 keep-alive 连接复用失败误认成“断连端点已经被调用”。这个细节体现了服务端计数的价值:客户端 IOException 本身不能证明请求已经到达预定位置。
重试是否允许,取决于操作语义与服务端执行状态,不只取决于异常名称。读取目录可以设计幂等重试,创建订单则可能在响应丢失前已经提交。请求方法、幂等键、重试次数、退避和总预算都需要共同分析,不能把开启默认重试视为透明优化。
本章对默认重试策略只读取固定源码与官方问题记录,没有把禁用重试的实验外推为默认配置行为。上游 HTTPCLIENT-2141涉及重试等待与响应超时关系,也说明这些策略必须绑定具体版本审查。
第二条可迁移模式是分开本地失败与远端结果。超时、取消或断连说明调用方没有拿到完整结果,不能单凭它们断言服务端没有执行。支付、消息发送和文件上传都需要处理这个不确定窗口。
取消结束等待,不证明远端回滚
取消实验在独立调用线程发起慢请求,等待服务端 latch 确认进入处理后,调用 request.cancel。Future 在有界时间内以 ExecutionException 结束,其原因属于 IOException,池中 leased 降为零。finally 停止调用线程和本机服务端,避免测试结束后继续保留任务。
这个结果证明本次本地等待被中止,并观察到连接占用释放。它没有证明服务端事务回滚,因为服务端已经接收到请求。示例服务端本来就没有数据库副作用,不能用它推导具有写入操作的远端服务结果。
同样,取消 Future 与取消具体 HTTP 请求未必通过相同路径传播。框架可能只停止等待者,或者中断线程但没有终止底层操作。验收应直接观察任务结束、连接状态和服务端进入情况,不能只检查 cancel 返回 true。
资源关闭也有层次:响应负责单次交换,连接管理器负责连接池,客户端通常管理自己拥有的组件,业务线程池则由创建者关闭。若显式共享连接管理器,关闭责任又会改变。本实验使用客户端拥有的管理器,没有验证共享管理器生命周期。
与 JDK HttpClient 对照
modern 实验使用 Java 11 引入的 java.net.http API,在实际 JDK 21 上运行,以字符串 body handler 接收五百零三响应。状态仍作为数据返回,body 为 unavailable,与 Apache 实验一样需要应用决定如何处理错误码。
另一个 JDK 实验为请求设置一百五十毫秒 timeout,访问不返回响应的本机端点,得到 HttpTimeoutException。这个观察只说明相应请求超时路径生效,不能把它解释为 Apache 连接池租用超时,也不能按参数名称直接建立一对一映射。
该对照使用 Java 11 已有接口,modern 工程按 Java 17 编译;JDK 8 不提供该客户端,因此没有尝试在基础套件中运行。示例显式停止自己创建的 executor 和测试服务端,字符串 handler 消费响应内容。没有测量两种客户端的吞吐、HTTP/2 或连接复用策略差异。
如果需求只有普通 HTTP 交换,JDK 客户端可以减少外部依赖;若需要 Apache 的连接管理能力、既有拦截器或特定配置,则应按这些真实需求比较。替换前仍要验证状态码、超时、取消和响应所有权,而不只是把 execute 改为 send。
实验与改动题
Chapter34Test 双 JDK 各四项,Chapter34ModernTest 在 JDK 21 两项,均以真实 loopback 服务端和客户端执行。原始日志与 XML 在 evidence/34。测试未覆盖 TLS、代理、DNS、黑洞连接建立超时、重试开启、HTTP/2 和生产负载。
反例题:第二个请求抛 ConnectionRequestTimeoutException,是否应该先联系服务端排查慢 SQL?不应直接这样归因。先检查连接是否被第一条未消费响应占用;实验中第二次等待甚至没有进入服务端处理阶段。
改动练习:给响应读取增加最大字节数,超过预算时结束读取并关闭响应。分别测试正常小体、超过预算与解析异常,断言 leased 最终为零;再明确提前关闭后是否要求连接可复用,不能把“已归还池槽位”和“原连接仍可复用”混为一个条件。
| 等待或动作 | 直接观察 | 本章边界 |
|---|---|---|
| 池租用 | leased=1 与租用超时异常 | 单连接、同路由 |
| 响应等待 | 服务端已进入与读取超时 | 未测总截止时间 |
| handler 结束 | leased=0 | 小响应正常及失败路径 |
| 请求取消 | Future失败与池释放 | 不证明远端回滚 |
| 客户端关闭 | 可用连接归零 | 非共享管理器 |
