一个 TCP 连接依次返回两份 HTTP 响应,客户端怎样确定第一份到哪里结束?等待 socket 关闭会阻止连接复用;把一次 recv() 当作一份响应,又会受实际读取分块影响。正确的边界来自 HTTP 消息规则,还依赖对应请求的方法与响应状态。

前置是第 02 篇字节流分帧和第 16 篇连接生命周期。本篇用标准库回环服务发送固定长度与分块响应,检查解析器实际消费的字节。缓存验证留到第 23 篇,TLS 身份验证留到第 24 篇。

方法说明操作,长度规则划分消息

请求行中的方法和目标指出请求的操作,后续字段提供 Host 等上下文。响应状态说明处理结果;这些语义不能由连接成功或收到了若干字节代替。例如收到完整错误响应,表示消息传输与解析可以成功,业务请求仍可能失败。

RFC 9110 §9.2分别定义安全方法与幂等方法。安全指方法的请求语义本质上只读,不承诺服务器完全不写日志。幂等比较重复相同请求对服务器的预期效果,不要求每次响应状态、时间或正文完全相同。因而“第一次响应丢了”并不自动授权重试任意操作。

HTTP/1.1 消息具有起始行、字段、空行,以及按规则存在的消息体。头部结束只说明可以开始决定正文边界,不说明整份响应已经结束。文本正文里也可能出现空行,不能靠搜索正文中的下一个空行定位下一份响应。

判断正文之前,要知道对应的是哪个请求

RFC 9112 §6.3规定长度判定的优先次序。HEAD 响应以及 1xx、204、304 响应在头部空行处结束,没有消息体或 trailer;成功 CONNECT 则在头部后切换为隧道,后续字节不再按普通 HTTP 响应正文解释。本实验不实现隧道。

HEAD 响应可能携带 Content-Length,用来描述相应 GET 的长度,而非承诺接下来真的发送这些正文。RFC 9110 §8.6还限定了 HEAD、304 等情形何时可以发送该字段。解析器若只看到 Content-Length: 5 就读取五字节,可能把下一响应的 HTTP/ 当成上一响应正文。

因此,响应解析器需要保留待处理请求的顺序及方法。一串无请求对应的多余字节,不能因为恰好长得像 HTTP 就当作正常的下一响应。HTTP/1.1 流水线中的响应顺序与请求顺序对应;保持一条连接也不意味着可以任意交错两份响应的正文。

已知长度时,只消费指定的字节

对允许正文、没有 Transfer-Encoding 且带有效 Content-Length 的响应,解析器消费精确的正文长度,并把多读到的字节留给后续消息。下面是教学字节串,不是抓包结果:

1
2
3
4
5
6
7
first = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"
second = b"HTTP/1.1 204 No Content\r\n\r\n"
stream = first + second
body_start = stream.index(b"\r\n\r\n") + 4
assert stream[body_start:body_start + 5] == b"hello"
assert stream[body_start + 5:] == second
assert len("网络".encode("utf-8")) == 6

Content-Length 数的是字节,不是字符数量。这里五字节的例子可以直接手算;换成 UTF-8 中文时,字符串长度不能代替编码后的字节长度。实现还必须约束可接受长度,不能依据未受限的远端整数直接分配任意大的缓冲区。

如果宣称五字节却在三字节后关闭,消息不完整,不能把 EOF 当作成功补齐。如果没有显式长度,一些普通响应可以由连接关闭定界,但此时无法再用这条连接承载下一响应。请求没有相同的“读到关闭即正文结束”通用规则。

chunked 的零块还不是最后一个字节

分块编码允许发送者逐块提供正文。每块先写十六进制数据长度和 CRLF,再写指定长度的数据及 CRLF;零长度块之后还有 trailer 区段,最后以空行结束。RFC 9112 §7.1给出语法。

1
2
3
4
5
6
7
8
9
以下用转义形式表示教学线缆字节:
4\r\nWiki\r\n
5\r\npedia\r\n
0\r\n
X-Lab-End: yes\r\n
\r\n

解码正文:Wikipedia(9 字节)
trailer:X-Lab-End: yes(不属于正文)

块长度只计算 chunk-data,不包含长度行和两侧 CRLF。零块之后立刻把解析器切回“读取下一响应”会把 trailer 误读成起始行;只读到 0\r\n 就报告完成,也无法发现最后空行的截断。

Trailer 与头部应分别保留,不能无条件合并。本篇用无业务含义的测试字段说明位置,不把它当作完整性认证。Transfer-Encoding 描述消息的传输编码,与描述表示编码的 Content-Encoding 不同;请求中的 TE 字段也不是 Transfer-Encoding 的缩写替代。

两种长度声明并存会造成歧义

如果同一报文同时携带 Content-Length 和 Transfer-Encoding,不同组件若采用不同边界,后续字节便可能被解释成不同消息。RFC 9112 §6.1–6.3 禁止发送者同时发送这两个字段;接收规则中传输编码优先,并不意味着这样的消息可以放心接受。

实验采用更窄的教学策略:遇到两者并存就拒绝解析并关闭自己的连接,不继续猜测后续边界。非法长度、正文截断和不完整块也作为失败处理。标准对相同重复 Content-Length 等情况有具体规则,这里的受限解析器不代表全部互操作策略。

严格拒绝几个错误输入不能证明完整防请求走私能力。真实中间代理、上游服务和客户端还可能对字段语法、协议转换及错误恢复有不同处理;本篇既不连接公共目标,也不做生产攻击验证。

连接复用和 TCP 保活解决不同问题

RFC 9112 §9.3规定 HTTP/1.1 默认使用持久连接,但连接能否继续复用仍受消息完整性、Connection 选项和端点关闭影响。客户端需读完整响应,服务端需读完请求或关闭连接,否则遗留字节会影响下次解析。

RFC 9293 §3.8.4说明 TCP keepalive 是传输层在空闲连接上的探测机制,不提供 HTTP 消息边界;SO_KEEPALIVE 开关也不等于 HTTP 的 Connection: keep-alive 字段。即使没有启用 TCP 保活,两次请求仍可共享一次 TCP 建连。本次通过读取 socket 选项并观察实际请求数验证这一点,不等待空闲探测定时器,也不测故障探测时间。

连接复用减少重复建连的需求,但不能由一次回环运行推导延迟或吞吐改善比例。HTTP/1.1 的顺序响应也可能让后续响应等待较早响应,跨流调度与传输阻塞将在 HTTP/2、QUIC 篇分别讨论。

代理两侧是独立连接

1
2
客户端 ── 连接 A ── 代理 ── 连接 B ── 源服务
各自解析边界、关闭状态和复用策略

客户端关闭 A 不等于 B 必然以相同时间关闭,也不能由 A 成功收到某些字节推定 B 已完成业务处理。代理可以重新组织消息,但必须保持语义及正确边界;逐跳的连接控制信息不能盲目透传。

RFC 9110 §7.6.1要求中间节点转发前解析 Connection,移除它指名的字段,并处理 Connection 本身。例如 Connection: X-Hop 指名的 X-Hop 只面向当前一跳,不应原样传到下一跳。这里的图和例子用于规则推导,实验没有运行代理。

实验记录

2026 年 9 月 20 日,macOS 27.0、Python 3.14.4 上的记录包含两组真实回环连接。正常组监听 127.0.0.1:59744,一次 accept 收到 GET /fixedGET /chunked。客户端先发两条请求再读响应,因此这是有对应请求的 HTTP/1.1 流水线;服务端按请求顺序发送两份响应。

检查位置 实际结果 能支持的结论
第一响应 Content-Length 为 5,正文 hello 消费指定的五字节
第一响应解析后 缓冲还保留第二响应的 115 字节 没有把后续响应吞进第一正文
第二响应 两块数据 world,正文 world 分块语法与正文分开
trailer 与结束 单独保存 x-lab: done,最终缓冲为空 读完零块后的字段及空行
两端 socket 选项 SO_KEEPALIVE 都为 0 这次复用不依赖 TCP 保活

完整响应串为 158 字节。服务端读请求、客户端读响应在该次运行中都只调用了一次有返回的 recv;这不代表一个 TCP 段,也不保证下次运行返回次数相同。实际读取字节按十六进制记录,可以把第一响应的 43 字节和第二响应的 115 字节直接分开核对。

第二组端口为 59746,仅接收一条 GET /bad,实际返回同时包含 Content-Length 和 Transfer-Encoding 的响应。客户端抛出 FramingError 并退出 socket 上下文,服务端随后 recv(1) 返回空字节。记录证明该条错误路径上的拒绝与对端 EOF,不证明具体 FIN/RST 报文或完整攻击防护。

离线自检把同一串 158 字节按 1、2、7、158 字节分批提供给解析器,均得到相同正文与 trailer。解析第一响应后,缓冲分别剩余 0、1、6、115 字节;差异来自输入批次,剩余数据没有被丢弃。另有 HEAD 优先规则和六种错误输入检查:两种长度并存、固定正文截断、缺最后空行、裸 LF、重复长度字段、超大长度。它们是人工输入测试,不是网络故障注入。

独立复跑的真实请求、响应正文和错误关闭结果一致。所有自建服务线程都已 join,socket 退出上下文;运行记录不含公共服务或宿主网络配置变更。

复现、练习与验证边界

附件提供标准库实验脚本实际运行记录环境与限制

1
2
3
python3 lab.py --help
python3 lab.py --self-check
python3 lab.py > local-run.jsonl

脚本需要 Python 3.11 或更新版本,只使用标准库;--self-check 不创建监听 socket。正常运行仅绑定回环临时端口,端口号以当次记录为准。连接操作超时为 3 秒,正文限制为 65536 字节,行与字段区另有上限;这些配置不是抵抗任意恶意慢速连接的实测结论。

解析器只实现 GET/HEAD 所需的受限响应规则,拒绝所有重复字段、块扩展、非纯 chunked 的传输编码和关闭定界正文,trailer 只接受 X-Lab。HEAD 仅经过离线验证;1xx/204/304 的无正文分支没有被扩展为完整的临时响应处理流程。没有真实代理、CONNECT/Upgrade、TLS、重试或生产兼容性验证。

一份 HEAD 响应带 Content-Length: 100,头部后立即出现下一份响应,应该消费多少正文?零字节;需要用待处理请求的方法解释长度字段。若是普通 GET 的固定长度响应声明 100,却只收到 99 就 EOF,则应报告不完整,不能沿用 HEAD 的例外。

对于 a\r\n 开始的非零块,需要多少数据字节?十个,因为长度为十六进制;数据后的 CRLF 另读。对于只有 0\r\n 就结束的输入,仍缺最后空行,不能报告完整分块消息。

如果记录显示一次 accept、两次请求和两份正确正文,但没有启用 SO_KEEPALIVE,可以得到什么结论?该次 HTTP 交换实际复用了连接,且不依赖 TCP 保活开关;不能由此证明空闲连接永不失效或任何性能比例。

一手资料

正文对应链接在写作时核对。RFC 9110 定义请求语义、字段与逐跳处理,RFC 9112 定义 HTTP/1.1 线缆格式、长度优先次序、分块与连接管理。规范结论与本机教学子集分开陈述,实验记录仅覆盖实际执行的输入和连接路径。