
一个接口按语义回了 204,body 里却塞了一句「订单 123 不存在」。调用方拿到空字符串,日志里只有一行 SyntaxError: Unexpected end of JSON input,看不出是谁把提示吃掉了。本机把这条路径缩到最小复现一遍:一个服务,几种响应形态,一次请求。
复现环境与几个端点
下面这个脚本能直接跑,服务监听随机端口,几个路径分别对应四种响应形态,最后一段绕开 fetch 直接抓 TCP 报文。
import http from "node:http";
import net from "node:net";
const server = http.createServer((req, res) => {
const p = req.url.split("?")[0];
if (p === "/204-with-body") {
res.writeHead(204, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "order 123 not found" }));
} else if (p === "/204-clean") {
res.writeHead(204);
res.end();
} else if (p === "/304") {
res.writeHead(304, { ETag: '"v1"' });
res.end("stale copy");
} else if (p === "/200-empty") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end("");
} else {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "order 123 not found" }));
}
});
await new Promise((r) => server.listen(0, "127.0.0.1", r));
const port = server.address().port;
for (const path of ["/204-with-body", "/204-clean", "/304", "/200-empty", "/200-with-json"]) {
const res = await fetch(`http://127.0.0.1:${port}${path}`);
const raw = await res.text();
let parsed;
try {
parsed = JSON.stringify(JSON.parse(raw));
} catch (e) {
parsed = e.constructor.name + ": " + e.message;
}
console.log(path, res.status, JSON.stringify(raw), "→", parsed);
}
function rawGet(path) {
return new Promise((resolve) => {
const sock = net.connect(port, "127.0.0.1", () => {
sock.write(`GET ${path} HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nConnection: close\r\n\r\n`);
});
let buf = "";
sock.on("data", (d) => (buf += d.toString("latin1")));
sock.on("end", () => resolve(buf));
});
}
const wire = await rawGet("/204-with-body");
console.log(JSON.stringify(wire), Buffer.byteLength(wire, "latin1"), wire.includes("order 123 not found"));
server.close();
先看带回 body 的那个 204
== GET /204-with-body
状态码 204 No Content
content-type application/json
content-length null
transfer-encoding null
读到 0 个字符 : ""
直接 JSON.parse SyntaxError: Unexpected end of JSON input
状态码是 204,content-type 留着,content-length 没有,transfer-encoding 也没有。客户端读到 0 个字符,紧接着按 JSON 解析,抛的是 Unexpected end of JSON input。这句报错只说明「内容为空」,说不出空的原因。
三个对照组
== GET /204-clean
状态码 204 No Content
content-type null
content-length null
transfer-encoding null
读到 0 个字符 : ""
直接 JSON.parse SyntaxError: Unexpected end of JSON input
干净的 204 连 content-type 都不带。
== GET /304
状态码 304 Not Modified
content-type null
content-length null
transfer-encoding null
读到 0 个字符 : ""
直接 JSON.parse SyntaxError: Unexpected end of JSON input
304 是同一类:ETag 这种校验头留着,body 同样一个字节没有,stale copy 那行字也没出去。条件请求命中缓存时走的就是这条路,客户端的判断逻辑里 204 和 304 要一起算。
== GET /200-empty
状态码 200 OK
content-type application/json
content-length null
transfer-encoding chunked
读到 0 个字符 : ""
直接 JSON.parse SyntaxError: Unexpected end of JSON input
200 加空 body 这一档,content-type 是 application/json,传输用了 chunked,客户端读到的字符数还是 0,解析照样失败。前面几个 0 字符的响应排在一起,客户端光看 body 分不出是 204 还是 200。
== GET /200-with-json
状态码 200 OK
content-type application/json
content-length null
transfer-encoding chunked
读到 31 个字符 : "{\"error\":\"order 123 not found\"}"
直接 JSON.parse {"error":"order 123 not found"}
只有 200 带 JSON 那次读到了内容,31 个字符,解析出一个 error 字段。
检查点:body 消失在哪一端
== 服务端自己有没有抱怨
警告数量 : 0
服务端进程没有报错,也没有警告,说明它不认为这次响应有什么问题。接下来把 fetch 换成裸 TCP,看报文里到底有没有那句提示。
== 原始报文(TCP 层,转义后逐字)==
"HTTP/1.1 204 No Content\r\nContent-Type: application/json\r\nDate: Fri, 25 Sep 2026 00:08:06 GMT\r\nConnection: close\r\n\r\n"
报文总字节数 : 115
body 那句在报文里吗 : false
报文一共 115 字节,头部只有状态行、Content-Type、Date 和 Connection: close,末尾是那个空行,body 一个字节都没有。order 123 not found 在报文里搜不到,所以它不是在传输途中丢的,也不是客户端丢的,服务端写响应的时候就没带出去。
排查顺序可以固定下来:先看客户端读到的字符数和状态码,再看响应头里跟长度有关的字段,最后抓一次原始报文。前两步只能确认症状,只有报文才分得清「压根没发」和「发出去被某一跳删了」这两种情况,而它们的修法完全不同。
Node 的 http 模块把 204 和 304 归入不许带 body 的状态,res.end(带内容) 的表现等同于只写了头部。这一点是按报文行为验证的,源码没去读。
客户端这一侧怎么兜
让 204 承载错误信息这条路本身不成立,需要传提示的接口应该用 404 或者 400,把 JSON 放进 body。客户端拿到 204 时应当直接当作「没有内容」,而不是继续解析。
客户端封装里能改的是一行判断:
async function readJson(res) {
if (res.status === 204 || res.status === 304) return null; // 协议上就不带 body
const text = await res.text();
if (!text) return null; // 200 但 body 是空的
return JSON.parse(text);
}
判断放在解析之前,空串有明确返回值,调用方拿到 null 就知道这次没有内容,不会把解析异常和「订单不存在」混在一起。需要区分「没有内容」和「内容解析失败」时,再看 res.status。
这里我把状态码判断放在读取 body 之前,204 和 304 直接返回 null,其余状态才去读内容。代价是每加一种不带 body 的状态就要在判断里补一条,好处是异常和空值不再混在一起,日志里那行 SyntaxError 也就不会再出现。
服务端那一侧的修法更简单:需要提示的接口不要用 204。204 的语义是「请求成功,没有内容要给你的」,既然打算把错误信息放进 body,就该用 404 或者 400,让状态码和内容指向同一件事。