Fastify 开启 gzip 后返回空正文,客户端报 Unexpected end of JSON input
Fastify 异步路由主动 reply.send 却未返回或等待 reply,可能在压缩流完成前结束响应。按发送契约收束 Promise,并核对压缩阈值与实际 GET 正文。
接口返回 200,响应头带着 Content-Encoding: gzip,解压后却是空字符串,客户端随后报 Unexpected end of JSON input。如果小响应正常、超过压缩阈值才失败,先检查异步路由有没有发完 reply.send() 就直接结束函数。
这条路径出现在 Fastify 5.8.5、@fastify/compress 8.3.1 的组合中。它需要几个条件碰到一起:处理函数是 async,里面主动调用 reply.send(payload),但没有返回或等待 reply,而发送过程经过压缩流等异步步骤。不能把所有 gzip 空响应都归到这里。
发送已经开始,异步函数却先完成了
下面的写法缺少发送完成与路由 Promise 之间的联系。payload 代表已经准备好的业务数据:
app.get('/items', async (request, reply) => {
reply.code(200).send(payload);
});
函数隐式完成为 undefined。Fastify 还会处理这个 Promise 的结果;在 5.8.5 中,如果此时 reply.sent、响应头状态等检查仍允许发送,可能再走一次 reply.send(undefined)。空载荷分支会结束响应,早先的压缩流因此来不及把内容送完。
这里的 reply.sent 反映响应是否结束或被接管,不等于“调用过 send”。没有压缩时,数据可能很快发完,使同一段代码暂时看起来正常。依赖这种时序,会让故障跟载荷大小、压缩配置和流的执行速度一起变化。
让一条路径负责完成响应
已有代码需要设置状态码并主动发送时,返回发送结果:
app.get('/items', async (request, reply) => {
return reply.code(200).send(payload);
});
也可以在处理函数里 await reply.code(200).send(payload)。若没有额外的发送需求,直接返回业务数据通常更清楚:
app.get('/items', async (request, reply) => {
reply.code(200);
return payload;
});
两种形式选一种,不要先 send(payload),随后又返回另一个业务对象。含有提前退出、异常处理或回调的路由也要检查全部分支,避免只修了正常路径。
Fastify 5.12.7 的正式路由文档仍要求异步处理函数主动发送时返回或等待 reply。因此这处修改是在遵守发送契约,不能用“已经升级”替代它,也不需要修改框架内部的 sent 标志。
比较实际 GET 正文,不只看响应头
用原来会失败的载荷分别经过不压缩和 gzip 路径,检查解压后的正文是否完整、能否按接口格式解析,并覆盖压缩阈值上下的大小。大对象和主动发送的流都应纳入相关路由的检查。
不要仅用 HEAD 请求确认结果:HEAD 本来就没有响应正文。HTTP 200、存在 gzip 响应头,或者小载荷能被解析,都不能证明原来的流已经完整发送。
若返回或等待 reply 后依旧为空,继续检查压缩前的实际载荷、流错误和代理层。对确实返回 204 的接口,客户端则不应尝试解码 JSON;它与这里的 200 压缩正文被截断是不同问题。


