fetch 错误处理器里的 body stream 只能读一次陷阱
项目前端有个手写的 fetchJSON 工具函数,一直运转正常。某天后端服务意外挂掉,前端控制台报出一条令人困惑的错误:
加载数据失败:Failed to execute 'text' on 'Response': body stream already read
第一反应是"fetch 封装出 bug 了",结果查了半天才发现:这条错误本身是个烟雾弹,真正的根因根本不在 fetch 封装里。
现象
封装里有一段经典的防御性写法:
// 看起来很合理——先尝试 JSON,失败了就降级读文本
if (!res.ok) {
let body: unknown
try {
body = await res.json() // (A)
} catch {
body = await res.text() // (B) ← 报错就在这里
}
throw new Error(`HTTP ${res.status}: ${JSON.stringify(body)}`)
}只要后端返回的是 JSON 格式的错误体,一切正常。但一旦后端挂了、代理返回了纯文本的 ECONNREFUSED,或者返回了 HTML 格式的 500 页面,(A) 的 res.json() 就会解析失败,然后 (B) 的 res.text() 立刻抛出"body stream already read"——真正的后端错误被这条次生错误完全掩盖。
根因
Response.body 是一个 单次消费的 ReadableStream。规范要求每次调用 res.json() / res.text() / res.arrayBuffer() 都会把这个流消费掉,并把 res.bodyUsed 置为 true。
关键在于:res.json() 即使抛出解析异常,也已经把流读完了。它先把字节读进来,再尝试 JSON.parse,parse 失败就抛错——但流已经空了,bodyUsed 已经是 true。此后再调 res.text() 就是对一个已耗尽的流做第二次读取,必然报错。
res.json() 的内部顺序是:先把 body 字节全部读入内存 → 再调 JSON.parse()。如果 parse 失败,bytes 已经被消费,流不会"退回去"。catch 块里再调 res.text() 只会得到"body stream already read",真正的错误信息永远消失了。
不同运行时的报错措辞略有不同:
- Chrome / Edge / WebKit:
Failed to execute 'text' on 'Response': body stream already read - Node.js 18+ / undici / Bun:
TypeError: Body has already been consumed - Firefox:
TypeError: Already read
正解
先用 res.text() 读一次拿到字符串,再对字符串做 JSON.parse。 字符串可以被解析任意次,ReadableStream 只能消费一次。
// ✅ 正确——body 只读一次,真实错误永远能拿到
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
})
// 单次读取 body,拿到原始文本
const text = await res.text()
let body: unknown = text
try {
body = text ? JSON.parse(text) : null
} catch {
// 不是 JSON 就保留原始文本,比如 HTML 500 页或纯文本代理错误
}
if (!res.ok) {
const msg = typeof body === "string" ? body : JSON.stringify(body)
// 截断,避免 200KB HTML 错误页把 toast 撑爆
throw new Error(`HTTP ${res.status} ${url}: ${msg.slice(0, 300)}`)
}
return body as T
}读一次 res.text() 得到字符串,再 try { JSON.parse(text) } catch 保留原始文本。这样不论后端返回 JSON、纯文本还是 HTML,body 只被消费一次,任何格式的错误信息都能完整呈现。
实测:修复前后的差异
| 后端响应 | 修复前看到的错误 | 修复后看到的错误 |
|---------|----------------|----------------|
| 服务未启动,代理返回 ECONNREFUSED | body stream already read | HTTP 500 /api/foo: ECONNREFUSED |
| 500 HTML 错误页 | body stream already read | HTTP 500 /api/foo: <!DOCTYPE html>... |
| 401 纯文本 unauthorized | body stream already read | HTTP 401 /api/foo: unauthorized |
| 400 JSON {"error":"bad_input"} | HTTP 400: {"error":"bad_input"} | HTTP 400 /api/foo: {"error":"bad_input"} |
修复封装后,真正的根因立刻暴露:开发时只启动了前端 dev server,忘了启动后端服务,代理把请求转发到死端口,返回了纯文本的 ECONNREFUSED。
备选方案:res.clone()
如果出于特殊原因必须调两次(比如 retry 中间件需要回放 body),可以在第一次读之前 clone:
const cloned = res.clone()
try {
return await res.json()
} catch {
return await cloned.text() // OK,不同 Response 实例,各有独立 stream
}但 clone() 会在内存里分配第二份 buffer。正常情况下优先用 text-then-parse 模式,clone 留给真正需要回放的场景。
Axios 内部会把 body 一次性读入 response.data,同时暴露解析后的对象和原始字符串,不存在二次读流的问题。这个坑是原生 fetch 特有的。
一句话外卖
body stream already read几乎从不是真正的 bug——它是某个上游错误被吞掉的信号,先修封装让真实错误浮出来,再去修真实错误。