深入 Play 16:WS 客户端的响应消费与请求预算
本地下游先发送响应头和 prefix-,随后等待闸门。普通 get 的 Stage 仍未完成,stream 的响应 Stage 已经完成,而消费响应体的 Stage 仍在等待。释放闸门,服务端写入 suffix,两条路径最终得到相同的 13 字节正文。
同一个 HTTP 响应具有不同的可观察完成点。调用者如果把“拿到 WSResponse”理解为“所有字节已消费、连接已可复用”,在流式路径上就会提前结束资源管理。超时放在哪里,也取决于它要覆盖哪个完成点。
固定客户端后端与实验输入
累计工程使用 Play 3.0.6、JDK 21,通过 javaWs 注入应用级 WSClient。解析产物为 standalone WS 3.0.6;其源码提交固定为 8380c047c5cf19182be56bdb831a28c9724e87bc。这里讨论的是该版本的 AHC 实现,不把 AsyncHttpClient、JDK HttpClient 或其他 WS 后端视作可互换语义。
底层构建记录对应 AsyncHttpClient 2.12.3 和 netty-reactive-streams 2.0.13。shaded jar 保留的 netty-buffer 4.1.115 元数据只证明这条模块记录,不能据此宣布所有 Netty 模块都为同一版本。构件名称、原始库版本与 shade 后包名应分别核对。
WsLab 创建两个仅绑定 127.0.0.1 的 HttpServer,使用不同随机端口。一个提供闸门响应与重定向,另一个记录重定向后的请求。所有凭据都是 synthetic-only 字符串,不访问外网或用户服务。
| 路径 | 下游控制点 | 观察对象 |
|---|---|---|
| /aggregate | 发 prefix 后暂停正文 | get 返回的 Stage |
| /stream | 发 prefix 后暂停正文 | 响应 Stage 与正文消费 Stage |
| /outer | 收到请求后暂停响应 | 外层 Futures.timeout 与原 WS Stage |
| /own | 收到请求后暂停响应 | WS request timeout 与下游终态 |
| /redirect | 返回不同端口的 Location | 跟随开关、目标次数、凭据头 |
闸门保证采样发生在已进入处理但尚未完整响应的阶段。把下游固定 sleep 一段时间,无法排除线程调度使采样发生得太晚;测试会偶尔观测到另一种顺序。
get 和 stream 在哪里完成
StandaloneAhcWSRequest 的 execute 与 stream 走不同客户端入口。普通路径的 ResponseAsyncCompletionHandler 在 onCompleted 中完成响应 Promise,失败则通过 onThrowable 传入。
流式路径单独保留 streamStarted 和 streamCompletion。返回给调用者的 Stage 对应前者;正文 Publisher 的完成信号还要结合后者。源码通过 executeStream 的返回与完成处理 表达了两个生命周期,响应对象已经存在并不代表 Publisher 已结束。
实际观测为 aggregateDoneAfterPrefix=false、streamResponseDoneBeforeBodyRelease=true、streamBodyDoneBeforeRelease=false。释放后两份正文均为 prefix-suffix,streamConsumedBytes=13,两个服务端处理器均到达 completed。
这组实验确认完成点差异,尚不能用 13 字节结果推导大响应的峰值内存。流是否有界取决于后续算子:runFold 拼接全部字节会保留完整正文,换成 Source 类型不会自动消除这部分存储。
正文预算也需要明确
以下完整类可放入累计工程 app 目录编译。调用端必须传入已经过策略校验的 trustedUrl;这个参数名只表达调用契约,不构成 URL 安全检查。示例显式关闭重定向,设置请求超时,再限制正文大小和消费完成时间。
1 | |
64 字节是本实验的正文消费上限,不是网络栈总内存上限。算子看到的元素可能已经由客户端读取并分配,TLS、HTTP 解析和连接缓冲也不由这个数值直接约束。真实下载还需要决定是否写入受限文件、遇到超限如何终止、谁清理未完成的输出。
响应流必须有明确终态:完整消费、失败或取消。只返回头部便丢弃正文引用,会使后续资源处理依赖后端行为。对共享注入的 WSClient,不应在每次请求结束时 close;该客户端及其连接资源由应用生命周期持有。Java WS 文档区分了注入客户端与手动创建客户端的管理责任。
主实验关闭的是自行创建的下游 HttpServer 和专用工作线程。serverWorkersTerminated=true 与 allGatedHandlersDone=true 证明这些实验资源结束,不等于测量了 WS 连接池的空闲连接数或归还时间。连接容量另由下面的专用客户端场景验证。
大正文取消以后,后续请求还能否完成
WsBoundaryLab 手动创建另一个 AHC 客户端,实际配置读回为每个 host 最多 1 个连接、总计最多 2 个连接。这里使用两个端口模拟不同 origin,总容量不能简单设为 1:保留的连接可能占据总容量,跨端口请求会受到 TooManyConnections 限制。配置声明应与客户端底层读回一起记录。
下游计划发送 256 个 16 KiB 块,总计 4 MiB。客户端对响应流采用 32 KiB 的 limitWeighted,再 take(1),消费第一个流元素即终止上游。这个元素的大小由网络与后端分块决定,不等于服务端一次 write 的 16 KiB,也不保证每次运行都相同。
本次客户端消费 1958 字节后取消;服务端记录完成写入 65536 字节,随后得到 IOException,处理器执行 finally。两端字节数不同,说明已写入本地连接的字节可能多于应用消费的字节,不能把 take(1) 理解为只在网络上发送一个业务块。
取消之后,同一个受限客户端向相同 origin 的 /ok 请求得到 200。这个结果结合每 host 单连接限制,证明该次取消没有使后续请求永久耗尽该 host 的可用容量;它没有证明复用了同一条 TCP 连接,也没有给出精确的连接归还延迟。客户端可能关闭旧连接并创建新连接,测试不把两种实现混为同一结论。
早期七个边界处理器均进入终态;加入四个截止时间处理器后,最新隔离记录为11次开始、11次结束,largeHandlerDone=true、clientClosed=true、serverWorkersTerminated=true。这个客户端由实验创建,资源持有对象负责关闭它;应用级共享 WSClient 仍由应用生命周期管理。
外层超时与 WS 自身超时
外层路径先设置 WS request timeout 为 3 秒,再用 Futures.timeout 包装为 100 毫秒。外层失败时,原 WS Stage 的 done=false、cancelled=false。释放下游后,原 Stage 获得 HTTP 200。这与前一篇的合成任务结果一致,但这里经过了真实 loopback socket。
WS 自身路径把 setRequestTimeout 设为 300 毫秒。其 Stage 以 TimeoutException 完成,wsDoneAtOwnTimeout=true。释放闸门后,本次 ownServerTerminal 仍为 completed,说明下游处理器在客户端结果失败以后继续走到了结束。
请求构建代码把 Duration 转为 AHC 请求超时设置。该配置进入客户端实现,作用位置与 Play 组合 Future 的等待超时不同;但客户端能够终止自身等待,不代表远端能够撤销已经执行的业务动作。
服务端写入在其他时序下可能暴露 IOException,因此测试只要求 ownServerTerminal 有终态,不把 completed 固定成每次网络行为承诺。也不能把一次写入成功当作客户端已收到这些字节:本地 write 返回与对端应用消费是不同事件。
如果上游有统一截止时间,连接建立、等待响应、消费正文、重试及跳转都要使用剩余预算。示例的两个 3 秒限制便于暴露两个阶段,却不能推出总耗时最多 3 秒;两个阶段各自计时与一个端到端截止时间不是同一契约。
重定向改变凭据的接收目标
重定向关闭时,实验得到 302,目标处理器调用次数为 0;显式打开时得到 200,目标被调用一次。目标与原地址使用同一个 IP,但端口不同,因此是不同的 origin。
目标实际收到以下合成头:
1 | |
这限定了一个可复核结论:固定 WS/AHC 版本、当前配置、显式 follow=true、同 IP 不同端口的跳转保留了这三种请求头。它没有证明跨主机、跨协议或任意客户端都如此。测试不能写成“跨 origin 一律剥离 Authorization”,因为实际输入已给出反例。
若凭据只授权给某个服务,应用需要同时限定首次目标和每次跳转目标。允许用户输入任意 URL,再仅检查字符串前缀,无法构成 SSRF 防护:协议、主机、端口、解析后的地址以及跳转都需要进入明确策略。DNS 变化与重绑定还需要网络层和解析策略共同约束。
边界实验把允许的 origin 固定为受信任 fixture 的 scheme、host、port 组合,不从 Location 动态扩充白名单。每跳关闭自动重定向,校验下一地址后才发送请求,同时拒绝含 userInfo 的 URI。最多处理三跳,超过则返回 REDIRECT_LIMIT。
| 输入 | 应用实际发送请求 | 禁止目标收到请求 | 结果 |
|---|---|---|---|
| 首地址已越过允许 origin | 0 | 0 | REJECTED_TARGET |
| 允许 origin 跳到禁止端口 | 1 | 0 | REJECTED_TARGET |
| 允许 origin 跳到相对 /ok | 2 | 0 | 200 |
| 只校验首地址,随后自动跟随 | 跟随后到达目标 | 1 | 200 |
这组反例证明首地址检查不足以约束后续接收方,逐跳校验在当前固定IP场景中阻断了不允许的端口。它没有实现通用域名解析防护,不覆盖DNS重绑定、代理解析差异、IPv6地址归一化或任意重定向状态的完整业务策略。原三秒逐请求路径保留为对照,下面另用同一个截止时间约束全部跳转。
实验始终使用固定本地目标和合成凭据。业务接口若能让调用者只提交资源标识,再由服务端映射到固定服务地址,可以减少用户可控制的网络目标;允许任意 URL 时,则需要覆盖完整解析与出站网络边界。
两跳共同消耗一份时间预算
WsBoundaryLab调用独立的WsDeadlineLab,其中fetchAllowedUntil在调用前用System.nanoTime生成截止时间,所有跳转沿用这个值。每跳先做固定origin校验,再把剩余纳秒转换为毫秒;不足一毫秒时返回DEADLINE_EXHAUSTED,不把截断后的零传给客户端。发送前再次检查截止时间,取得完整响应后也检查,成功结果必须仍在预算内。
1 | |
这段是累计工程的源码切片,get为有限等待辅助方法。完整实现还记录每跳状态、请求计数和异常,并保留三跳上限;正常get等待聚合正文,因此当前逐跳预算包含该正文消费。大规模流式业务仍要限制正文大小,并把同一截止时间带入消费阶段,不能照搬无限聚合。
| 受控场景 | 已发送请求 | 观测 |
|---|---|---|
| 调用前截止时间已过 | 0 | DEADLINE_EXHAUSTED,处理器没有进入 |
| 总预算1秒,第二跳进入后独立延迟1350毫秒放行 | 2 | TIMEOUT时尚未放行;随后处理器有限终止 |
| 总预算3秒,两跳分别释放 | 2 | 302、200,正文deadline-ok,剩余时间为正 |
最新隔离验收的一秒样本,每跳配置由999毫秒缩到845毫秒;最终观察到异常用了1085毫秒。调度与定时器粒度会使异常被观察得晚于截止时间,不能把它改写成严格墙钟不超过1000毫秒。第二处理器有自己的1350毫秒定时放行,不依赖调用者先抛异常;因此固定每跳3秒的错误实现会等到第二跳200,而正确实现提前TIMEOUT。临时单行变异保留响应后的截止检查,实际变成DEADLINE_EXHAUSTED,测试退出1;恢复正确代码后隔离34项测试与stage通过。原始记录在batch12-17/deadline-repair-isolated。
另一个真实场景在第二处理器进入后中断协调线程。原InterruptedException与中断标记保留,九个已进入处理器均终止;自建客户端、工作池、调用池与定时器关闭,两处监听端口连接被拒绝。关闭多个资源时应继续尝试剩余资源,并把清理失败附在主异常上;第一项关闭抛错后直接跳过其他关闭,会让实验本身泄漏资源。有限场景的清理成功仍不证明下游业务被撤销。
重跑与练习
从第00篇取得累计工程,在 play-lab 目录执行:
1 | |
核对realLoopbackWsShowsCompletionTimeoutAndRedirectBoundaries输出的BUDGET ws JSON,以及其中boundaries对象。隔离应用30项JUnit与stage通过,原始日志、XML与观测保存在evidence/batch15-17/isolated/;它启动真实loopback下游,没有使用WS mock。共享工程另通过34项测试,python3 lab/async_checks.py --dev和python3 lab/async_checks.py各完成198次真实HTTP检查,其中GET /budget/ws重新运行全部下游边界实验,记录位于batch12-17。Helpers与真实服务器证据分别保留。
反例题:stream 返回 200 后立刻记录“下载成功”,随后正文超时。这个指标应该如何调整?至少分开记录响应头状态、消费字节数和正文终态;只有业务所需正文处理完成,才能计为下载成功。
改动练习:在fetchAllowedUntil上增加最多三次声明的可重试失败,保持所有尝试共用原截止时间。让首轮消耗大部分预算,再观察第二轮实际配置与尝试数;已耗尽时不得发送第三轮。另记录目标处理器次数和终态,不能只检查调用者抛出了超时。

