
同一份 Response 里,Content-Length 是 32,await response.arrayBuffer() 却只有 12 个字节。正文是 hello 中文,没有少数据。差别来自 gzip:服务器发出 32 字节压缩内容,Node 的 Fetch 在读取时已经解压了它。
这个差异平时不显眼。直接把正文交给 JSON 解析器,一切照常;如果把 Fetch 得到的头和正文一起转发,问题就出来了。下游收到的是解压后的字节,头部却还声称内容采用 gzip,并且给出压缩后的长度。
回环响应里有两个长度
验证脚本 tools/verify-fetch-gzip-headers.mjs 在本机开了一个 HTTP 服务。服务先用 Node 的 gzipSync 压缩固定文本,再将压缩字节长度写进 Content-Length,同时设置 Content-Encoding: gzip。客户端通过内置 fetch() 读取同一个地址。
Node 版本是 v26.8.1。输出如下:
{
"node": "v26.8.1",
"requestAcceptEncoding": "gzip, deflate",
"status": 200,
"contentEncoding": "gzip",
"contentLength": "32",
"compressedBytes": 32,
"decodedBytes": 12,
"text": "hello 中文"
}
服务端看到请求允许 gzip,响应的压缩正文长 32 字节。Fetch 返回的正文已经解压,字节长度变成 12,转成 UTF-8 后仍是原文。可见 response.headers 没有随着正文变换而改写这两个响应头。
Content-Length 描述的是消息正文在线路上的字节数,不是应用把内容解码后看到的长度。测得的 32 和 12 正好对应两个不同阶段。只看 response.text() 的文本结果,很容易忽略这个边界。
转发时头和正文要配套
常见的代理代码会把上游状态码和头复制到新响应,再把正文流交给下游。如果上游是 gzip,而 Fetch 已经解压了流,这种照搬会制造一份自相矛盾的响应。下游按 gzip 解码时拿到的却是明文,按长度读取时也可能等待不存在的字节,或把后续内容当成当前响应的一部分。
修复方式取决于转发哪一份正文。转发 Fetch 解压后的内容,就应移除 content-encoding 和 content-length,让下游服务器按实际输出决定长度或采用分块传输。若确实要原样转发压缩字节,则不能把已解压的 Fetch 正文当成原始字节使用,需要选择保留压缩内容的传输路径,并让头部继续准确描述它。
我更倾向于让代理清楚地选择其中一种表示,不要一边处理解压后的数据,一边又假设它和线上字节相同。压缩通常会把长度变小,但具体比例由内容决定;这里的 32 与 12 只是固定样例,不是可以拿来估算流量的比例。
Accept-Encoding 是请求头,表示客户端愿意接收什么编码;响应的 Content-Encoding 才说明服务端实际选了什么。脚本的请求头里有 gzip,所以服务端选择 gzip 有据可查。若客户端没有声明支持某种编码,服务端本就不该强行使用它,这与客户端收到后如何读取是两个环节。
排查错位时,把上游头部与应用读出的长度放在一起对照,比只打印文本更容易定位是哪一层改变了正文。对完全由 Fetch 消费的响应,按解码后的内容正常处理即可;只有把响应继续转发或自行解释长度时,才需要明确压缩边界。头部里的数字属于线路表示,业务代码里拿到的字节属于解码后的表示,二者不必相等。
Node 26.8.1 的验证范围
本机脚本验证了 Node 26.8.1 内置 Fetch 访问本机 HTTP 服务的行为。浏览器端的表现没有验证,代理是否会重写响应也不在这次测试范围内。若线上链路里有中间代理,最终头部可能被它改写,单靠这里的结果不能推断那一段。