上篇区分了提交请求、后台任务和 HTTP 服务器三个所有者。如果任务在统计前还需访问另一个 HTTP 服务,第四个时间边界就出现了:连接何时建好,服务器何时开始应答,响应体何时读完,以及任务被取消时这次下游请求由谁停止。本章在同一个累计任务服务的分析路径上接入可选的合成下游;完整代码与实验包、SHA-256及独立解包指令保留本次正反证据。

建连预算不能代替整个请求期限

固定 Go 1.27.1 的 net.Dialer.Timeout 文档说它控制一次连接建立最多等待多久;DNS 得到多个地址时,这个预算还可能被分配到不同候选。它不能限制已连上以后服务器要多久才发响应。http.Client.Timeout 的范围更大:包括连接、重定向和读取响应体;Client.Do 已返回响应头后,读取 Body 时这个计时器仍然有效。任务自己的 context 是另一条取消通路,来自服务而非提交 HTTP 请求的 context。三者不是“都设 30ms 所以完全一样”的参数别名。

任务分析器可选地先执行 probeDownstream(taskContext, client, baseURL, text),在请求 URL 中传一个经 url.QueryEscape 编码的受控文本,出站 http.NewRequestWithContext 使用该任务 context。下游检测成功才运行原来的文本统计;状态提交仍走原 taskExecutor 与 TaskStore。客户端和 Transport 由调用方创建并复用,不是每项任务临时新建一套连接池。若下游 503 或超时,任务记录进入 FAILED;若此任务显式被取消,出站请求收到取消,等分析器实际返回后记录 CANCELED。这里下游是合成测试服务,没有对用户数据或真实外部系统发请求。

受控慢响应测试把真实本地 TCP 建连和响应等待拆开:客户端 Transport 配 net.Dialer{Timeout: 30ms},连接 localhost 成功;测试下游收到请求后不发响应头,直到请求 context 被取消。http.Client{Timeout: 150ms} 到期,调用者实际读到可用 errors.Is 识别的 context.DeadlineExceeded,服务端也观察到请求结束。于是本次 150ms 是请求期限引发的失败,而非“建连用了 150ms”。另一个测试用注入的 Transport.DialContext 故意阻塞到模拟的 30ms 拨号预算到期,从而在没有 HTTP 响应时观察拨号错误。后者没有向真实黑洞地址做 OS TCP 超时测量,这个项目仍是 NOT_RUN;不能把测试替身写成 Linux 内核对黑洞网络的超时保证。

任务服务还做了对应的反例:提交 API 先返回 202,下游一直不应答,只有出站 Client.Timeout 到期,没有调用任务取消 API。服务等分析器退出后查到 FAILED,CancelRequested 为 false。下游期限到期不能冒充用户请求取消;HTTP 202 也不表示统计任务完成。

错误状态的响应体也必须归还

Client.Do 返回 HTTP 503 时,通常是有响应、没有请求层错误。若只在 200 分支读取和关闭 Response.Body,错误路径的连接资源便可能不按预期重用。本工程在得到响应后立即约定调用方 defer Body.Close(),用 io.LimitReader 最多读取 4097 字节;成功与 503 的短响应都读到 EOF,再按 HTTP 状态分类。若超过本工程定的 4096 字节上限,就返回 ErrDownstreamBodyTooLarge 并关闭 Body;这种早停没有读尽,不能因此许诺这条 HTTP/1.1 连接继续复用。4096 是本任务应用的边界,不是 Go 对下游响应的默认限制。

测试让一个 httptest 下游交替返回短 200 和短 503,共顺序请求 40 次,两个分支都读取并关闭 body;通过该服务的 ConnState(StateNew) 本轮只观察到 1 条新连接,测试设的上限断言是 2。再返回 4097 字节触发过大响应反例,不能把它和短错误体一起计入“都能复用”。同一任务服务将 Go、bad、huge 三项提交给原 worker:短成功记录 SUCCEEDED,下游 503 与超大 body 各记录 FAILED。一次 40 项顺序复用和新连接数 1,足以说明本轮处理了错误 body 与复用,但不是长期峰值、堆大小或真实网络的资源预算证明。

服务拥有的取消能到达出站请求

若任务主动取消时正卡在下游响应上,仅停止接收下一项任务没有用。出站 NewRequestWithContext(taskContext, ...) 让任务取消触达客户端 Transport。TestTaskCancelInterruptsDownstreamRequest 的合成下游先收到请求后等待其入站 context 结束;按 ID 主动取消任务,观察下游请求确实退出,然后服务自己的业务 Close 等 worker 收尾,最终按 ID 查到 CANCELED。中间的取消 API 202 仍只是通知已发;不能拿它代替下游请求退出、分析器返回或任务终态。反向若只是先前章节的提交客户端在收到 202 后断开,该取消不会自动穿透到这条出站请求,因为那时任务已经由服务接管。

net/http.Response.Body 本版文档要求调用者关闭 Body,且未读尽并关闭时 keepalive 连接可能无法复用;并不承诺“只要调用 Close,任何大小与任何错误的响应都一定会复用”。本章有意将正确性错误(非 200、过大、deadline、显式任务取消)与连接资源各自记录:错误应返回可判断的结果,Body 在各实际拿到 Response 的分支关闭;如果 Client.Do 在得到响应前已经因期限失败,则根本没有 Body 可关。客户端复用还意味着 Transport 内部有状态,调用方在停止使用它时要安排关闭空闲连接,不能按请求反复构造客户端并靠一次 GC 推测资源释放。

固定环境为 Go 1.27.1/linux-amd64、GOTOOLCHAIN=local、GOMAXPROCS=2、CGO_ENABLED=1、gcc 13.3.0。四条受控测试单次与 20 次重跑均退出 0,两包完整回归、本机受控路径和两包 go test -race 本轮退出 0;无 race 报告仅限这次覆盖的调度/输入。旧 CLI 的正常批次与零期限错误路径也用真实二进制复测。具体配置、命令、stdout/stderr 和退出码在 writing-plans/go-language/evidence/24/,静态页面检查另计。

练习:把 net.Dialer.Timeout 改成 10ms,能让已经连接成功却 2 秒不发响应头的下游在 10ms 内失败吗?答案:不能。拨号预算已经在连接成功时结束;该阶段应有任务 context 的 deadline 或客户端的请求总期限。反过来,只给 Client.Timeout 也不必自动知道一段时间究竟耗在 DNS、建连、服务器还是 Body;需要按阶段打点,才能给连接错误与慢响应分别定位。若服务器返回 503,仍要处理并关闭它的 Body,不能因为状态不是 200 就跳过资源责任。

本章仍是可注入的合成下游与真实 loopback 请求,没有 CLI 常驻监听/真实外部依赖部署,没有真实 OS 黑洞 TCP 超时测量,也没有长时资源增长或其他平台验证。下一章才引入实际 SQL 驱动、数据库与连接池;数据库提交和外部副作用不能因为一次 HTTP 下游应答被归为同一业务完成动作。源码 tag go1.27.1、镜像查询完整 Git SHA 862c888e612ac346c7c4d99c9392bdfd265f33b0 沿用基线,官方 Gitiles 直接交叉核验仍 NOT_RUN。全站构建含本篇、发布均 NOT_RUN。

参考资料