侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 123 篇文章
  • 累计收到 7 条评论

204 带着错误信息回来,客户端只拿到空串

2026-9-25 / 0 评论 / 5 阅读

204 带着错误信息回来,客户端只拿到空串

一个接口按语义回了 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,让状态码和内容指向同一件事。

    🤞 分享