ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

fetchEventSource与fetch流式请求实战:AbortSignal复用引发的failed to fetch排查

fetchEventSource与fetch流式请求实战:AbortSignal复用引发的failed to fetch排查 1. 先交代背景我是怎么踩进这个坑的最近在做一个 AI 对话前端改造需要把大模型回答从“等半天一次性吐出来”改成“边生成边渲染”的流式效果。需求本身不复杂但落地时却让我在fetchEventSource和原生fetch之间反复横跳折腾了整整两天。最崩溃的一条报错长这样failed to fetch dynamically im 无法加载 agent 预设 client api: agentpresets/list failed: failed to fetch明明上一秒接口还能通换掉请求方式之后水灵灵地就开始failed to fetch而且只有流式相关接口跪了普通 JSON 接口一切正常。后来我把整套链路从浏览器到服务端到 SSL 全查了一遍最后才发现问题不是网络不是网关而是我跟fetchEventSource之间有层“没有说透的窗户纸”。这篇文章不打算写那种“fetchEventSource 比 fetch 好”的结论帖而是想把我这次真实的排查过程拆开讲清楚两个东西在流式场景下的本质区别。如果你也在做 SSE 流式输出、大模型实时渲染或者遇到failed to fetch、agentpresets/list failed、abort被莫名触发这类报错这篇文章里的排查思路和结论应该能帮你少走不少弯路。先说结论要点原生fetch支持读流但它只是“给了你水管”fetchEventSource则是一套完整的水泵系统。两者在流式场景下的区别主要集中在四个地方——事件解析、断线重连、请求头约束、以及中止信号的语义。搞懂这四点几乎所有流式报错都能定位。2. 重新认识两个读取方式fetchEventSource 与 fetch 的本质差别2.1 原生 fetch 的流式是“给了水管但没给你水泵”原生fetch从很早开始就支持读取流式响应了核心就三个 APIconst response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt: 你好 }), }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 自己解析 buffer 里的数据 // SSE 格式通常是data: {content:xxx}\n\n const events buffer.split(\n\n); buffer events.pop(); for (const event of events) { const dataLine event.startsWith(data:) ? event.slice(5).trim() : ; if (dataLine dataLine ! [DONE]) { const json JSON.parse(dataLine); renderContent(json.content); } } }你看原生fetch其实完全能干这事。它把response.body变成了一个ReadableStream你每次reader.read()拿到一块 Uint8Array然后自己解码、自己切分、自己解析事件。也就是说原生 fetch 的能力边界是“给你一根水管水流过来你自己接”。这里能满足基本的流式需求而且足够轻量。但问题在于它太“原生”了很多坑留给了使用者。比如SSE 协议规定事件之间用空行分隔但网络分包可能把一个事件切成两半你得自己维护 buffer如果服务端发了注释行以:开头的行用于心跳保活你得自己跳过断线了不会自动重连得自己写重试逻辑请求头虽然随便你加但服务端 CORS 是否会暴露、预检请求能否通过依然要自己处理。这些“自己来”的部分看着都不难但叠加在一起就是典型的多处逻辑交织、边界问题频发的状态。我第一次用原生 fetch 写流式60 行代码里有 30 行都在处理字符串切分和异常兜底。2.2 fetchEventSource可以理解为“配备了泵、阀门和仪表盘的成套方案”fetchEventSource是微软出的一个库本质是在fetch之上做了一层封装专门针对 SSE 流式场景。它解决的核心痛点是EventSource天然只支持 GET不能用 POST 传业务参数也不能自定义请求头比如带上 Authorization 令牌而 AI 对话类接口几乎都是 POST JSON 鉴权头。fetchEventSource用fetch重新实现了 SSE 的完整行为保留了 EventSource 的事件语义同时突破了它的请求约束。它的基本用法很短import { fetchEventSource } from microsoft/fetch-event-source; const ctrl new AbortController(); await fetchEventSource(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ prompt: 你好 }), signal: ctrl.signal, openWhenHidden: true, // 页面隐藏时保持连接 async onopen(response) { if (response.ok) { console.log(连接建立状态码, response.status); } else { throw new Error(HTTP ${response.status}); } }, onmessage(event) { const data JSON.parse(event.data); renderContent(data.content); }, onclose() { console.log(流正常关闭); }, onerror(error) { console.error(流异常尝试重连, error); // 返回非 void 时库会自动重连 // 如果不想重连可以 throw error }, });这套 API 看起来清爽多了。onmessage帮你把 SSE 的data:行解析好event.data直接就是内容onerror里返回一个值库会帮你做自动重连连接建立、打开、失败、关闭全都有回调钩子。还有openWhenHidden这个参数处理了页面切换 Tab 时浏览器对连接的限制原生 EventSource 在这个场景下有个痛点是隐藏页面会挂起这库能绕开。用一句生活化的话说原生 fetch 给你一根水管让你自己装水泵、装水表、装阀门fetchEventSource直接交付一套集成好的供水系统你只需要打开龙头。2.3 为什么说“自定义 header”是分水岭很多人在选型时纠结“为什么不用 EventSource它不也是 SSE 吗”——这就是没踩过真实需求场景才会有的疑问。原生EventSource的问题非常致命它不能带自定义请求头。你拿它调一个需要Authorization的私有化模型接口直接就是 401 甚至跨域预检失败。现在不少 LLM 网关还要求请求里带api-key、trace-id这玩意儿根本塞不进去。所以这时候有两类选择用原生fetch自己解析流请求头管够代价是解析逻辑、断线重连、心跳保护全手写用fetchEventSource它内部用 fetch 实现自定义 header、POST body 都支持同时把 SSE 协议的上层语义补齐。我在这次改造里选的是fetchEventSource理由很简单——我们的服务端网关要求每个流请求都带内部api-key和request-id原生 EventSource 直接出局而项目里又要求快速交付手写解析器的维护成本不低用一个成熟封装更稳妥。但正是这次选型让一个问题暴露出来fetchEventSource太好用了以至于让我忽略了它内部对“错误响应”和“连接中止”有一套自己的处理逻辑而这套逻辑在某些场景下和原生fetch的语义完全不一致。3. 真实踩坑过程一次 agent 预设加载失败引发的排查3.1 现象复盘先还原一下我当时的场景。项目里有一个“智能体预设列表”的接口/api/agentpresets/list用来给对话页加载可选的 AI 角色。这不是个大模型流式接口而是普通参数列表接口。但当时前端统一把这类接口从fetch切换成了fetchEventSource——因为我天真地以为“既然都是走 HTTP统一封装组件最省事”。切换之后一连串接口开始报错failed to fetch 无法加载 agent 预设 client api: agentpresets/list failed: failed to fetch注意最后一次报错这是浏览器终端的原始信息翻译过来就是fetch在请求还没有拿到任何响应头之前连接就被中止了。因为fetchEventSource的onopen回调只有在收到响应头之后才会触发而这次请求连这一步都没走到。我第一反应是服务端挂了。于是用 Postman 直接打同一个接口200秒回数据完整。再用 curl 打200一切正常。那么问题就出在前端请求本身。3.2 排查链路我按下面这个顺序排除写下来给同样踩坑的人参考第一层看请求是否真正发出。打开 DevTools 的 Network 面板在agentpresets/list请求上右键复制为 curl命令行跑一遍。如果 curl 能通说明服务端、网关、SSL 都没问题。剩下的问题集中在浏览器环境和请求库。第二层查 CORS 和预检。我们的接口带Authorization和api-key自定义头浏览器会先发一个OPTIONS预检请求。看 Network 面板里是否有预检请求预检是否返回了正确的Access-Control-Allow-Headers这一步很关键因为fetchEventSource内部即使设置了 headers如果服务端没放行这些自定义头请求在预检阶段就被浏览器拦截了表现就是failed to fetch。这个坑非常经典尤其是从 Postman 测不出问题的情况下十有八九卡在这。第三层查代理层和网关是否对流式请求做了特殊处理。我们服务端有个 Nginx 网关检查proxy_read_timeout、proxy_buffering这类配置。如果proxy_buffering开着SSE 流的响应会被 Nginx 攒着不吐客户端迟迟收不到第一个字节容易触发表层超时。虽然这里报的是“预设列表”接口但网关是统一入口配置影响所有接口。第四层查 AbortController 与页面生命周期。我们的对话页在组件卸载时会调用ctrl.abort()取消未完成的流式请求。如果请求时序上组件先卸载、请求后返回那么 abort 信号会导致 fetch 以AbortError结束最终同样表现为failed to fetch。这个在所有异步请求中都可能发生属于经典竞态。四层查完前三层都没问题第四层嫌疑最大。于是我打开 Network 面板盯着预设列表请求的 timing发现 Grunt 一个巧合这个接口发出的时机和上一个流式请求 abort 的时机几乎重叠。3.3 根因定位到这里真相就比较清晰了。我们的对话页切换 agent 预设时会先abort()上一个流式请求再发起新的预设列表请求。而fetchEventSource有个重要特性它内部维护的是同一个AbortSignal信号链。如果你在fetchEventSource的选项里传入某个signal它内部的所有重连尝试都会复用这个信号。问题出在我没有为每次请求创建独立的AbortController而是模板里复用了同一个。第一次请求 abort 后这个 controller 的 signal 状态变成了aborted接下来所有复用这个 signal 的请求fetch 都会立即拒绝根本不会发出网络请求。换句话说fetchEventSource的signal一旦 abort 就永久失效它是“一次性信号”。而原生fetch遇到同样的情况也一样是被 abort 拉住——这不算 fetchEventSource 独有的问题但因为fetchEventSource内部消息循环和重连机制的存在这种“被 abort 拒绝”的请求其错误信息里没有明确的AbortError标记而是被转换成了TypeError: Failed to fetch所以排查时很容易误判成网络问题。再进一步看为什么这个报错会串到agentpresets/list这样完全无关的接口上就是因为我把同一个AbortController传给了所有接口请求。表面上代码是这个样子的// 错误示范所有请求复用一个 controller const sharedController new AbortController(); async function loadPresets() { await fetchEventSource(/api/agentpresets/list, { signal: sharedController.signal, onmessage(msg) { /* 处理 */ }, }); } async function chatStream() { await fetchEventSource(/api/chat, { signal: sharedController.signal, onmessage(msg) { /* 处理 */ }, }); }一旦某个环节调用了sharedController.abort()后面再发的任何复用请求都不再有意义。这不是fetchEventSource的问题而是我对“AbortSignal 是一次性状态”这个底层语义理解不到位——所以我在标题里强调这是一次“本质区别”本质不是 API 长什么样而是状态语义。修复办法非常简单每次请求都 new 一个独立的AbortControllerasync function loadPresets() { const ctrl new AbortController(); await fetchEventSource(/api/agentpresets/list, { signal: ctrl.signal, onmessage(msg) { /* 处理 */ }, }); } async function chatStream() { const ctrl new AbortController(); await fetchEventSource(/api/chat, { signal: ctrl.signal, onmessage(msg) { /* 处理 */ }, }); }如果你真的需要在某个页面级别统一取消所有请求也建议维护一个 controller 集合而不是共用一个AbortController。每次请求创建独立 controller页面卸载时统一调用集合里的abort()。注意AbortSignal一旦进入 aborted 状态是无法恢复的。你没法把同一个 signal 取消后再复用。这是 web 平台的固定语义跟库无关。4. 避坑经验与报错速查表4.1 选型建议什么时候用 fetchEventSource什么时候用原生 fetch这次踩坑之后我把“流式读取”的选型标准重新梳理了一遍。没有哪个方式是绝对正确的只有更适合你当前场景的。场景推荐方式原因大模型对话需要 POST 自定义 header SSEfetchEventSource自动解析事件、自动重连、支持 POST 和 header后端就是标准 GET SSE比如某些开源消息推送原生EventSource浏览器原生能力不需要引库天然支持自动重连只需要非常轻量的单次响应读取不关心重连原生fetchReadableStream依赖少代码可控涉及复杂的多遍流处理、事件类型多样、需要精细控制每类事件fetchEventSource它的onopen/onmessage/onerror/onclose钩子比原生fetch的裸流处理清晰得多项目对依赖包体积极其敏感原生fetch少一个运行时依赖打包体积自然减小我个人的经验是如果你在做 AI 对话类功能第一选择就是fetchEventSource。它让你把精力花在业务逻辑上不用每次纠结字符串切拆和心跳处理。但代价是——它是个封装层你踩的坑往往不是它本身不够好而是你没搞懂它背后依赖的底层语义比如 AbortSignal 的一次性特性、自动重连可能带来的重复数据问题。4.2 常见报错速查表把这次项目里遇到的和网上高频出现的问题整理成了一张速查表按failed to fetch相关错误类型和排查路径给出来报错关键词可能原因排查顺序failed to fetch跨域预检失败、服务端未响应、连接被 abort、网关 buffer1. Network 复制 curl 验证服务端 2. 检查 OPTIONS 预检 3. 检查 body 是否被 abortfailed to fetch dynamically只用于动态导入和运行时 fetch 无直接关系但报错前常伴随网络不可达或单页应用资源加载失败检查静态资源 CDN 可达性、路由目录是否正确agentpresets/list failed: failed to fetch请求被 AbortSignal 拦截、或者自定义 header 未通过 CORS重点查 AbortController 是否被复用、预检响应头connect econnrefused服务端端口未监听、防火墙拦截、服务未启动curl -v看握手过程检查服务日志failed to fetch version from claude.ai这是某些工具在检测网络或版本源时的通用错误多数和代理/证书/网络隔离相关换网络源看是否能通检查系统代理设置git fetch或git pull很慢缓冲区容量、协议差异、DNS 解析慢git config --global http.postBuffer调大检查https.sslVerifyVS Code 服务器failed to fetch远程环境下载 server 包失败手动下载vscode-server-linux-x64.tar.gz放到指定目录这里必须强调failed to fetch是前端最常见但又最没有信息量的错误。小技巧是在onerror回调里加一层错误转换把error.name和error.message都打出来。如果error.name AbortError说明是主动中止如果error.message含NetworkError说明是连接层面的问题如果是TypeError: Failed to fetch但实际请求没有发出大概率是 CORS 或 signal 问题。这一手能在你上 DevTools 之前先快速缩小范围。另外一个小技巧如果你需要排查“请求到底有没有发到服务器”可以在组件里临时给fetchEventSource加一个onopen回调onopen(response) { console.log(HTTP 状态, response.status, 说明服务端已收到请求); }只要onopen执行了说明服务端已返回响应头问题不在“服务端没收到请求”。如果onopen一直不执行那就是请求没到服务端优先查 CORS、DNS、证书、signal。这个“响应头是否返回”的判断思路能把你从“服务端到底通没通”的泥潭里拉出来。4.3 一个额外的坑重连造成的重复数据除了 AbortSignal 的坑fetchEventSource自动重连机制还会带来另一个问题断线重连后消息可能重复。比如你调大模型接口流式返回了一部分内容后网络闪断fetchEventSource会自动重连并重新发送请求。如果服务端没有做“断点续传”或者“请求去重”那前端就会再次收到从第一条开始的内容界面上就出现了重复的渲染。我当时调的是一个内部 LLM 网关网关并不缓存历史输出重连后从零开始生成前端渲染里就出现了两遍回答拼接的诡异效果。这类问题的处理思路有两个方向前端做消息幂等靠event.id或递增序号重复内容直接丢弃重连后让用户手动确认“是否继续上次回答”而不是无感重放。对于 AI 对话这种场景自动重连不总是好事。服务端生成状态已经在第一轮请求里消耗过一遍了重连不是在“继续生成”而是在“重新生成”此时自动重连反而制造混乱。所以我后来把onerror改成了手动控制onerror(err) { // 打印原始错误 console.error(流产生错误, err.name, err.message); // 如果是 AbortError说明是用户/组件主动中止不重连 if (err.name AbortError) { throw err; } // 其他错误默认自动重连这里不返回具体值即可 // 如果你希望手动控制直接 throw 出去 throw err; }实际项目里我会区分错误类型来决策是否重连。主动 abort 的重试毫无意义网络抖动且服务端支持幂等时自动重连才值得开。这样的决策能力是裸fetch和fetchEventSource都很难替你做主的都需要你对业务流有清晰判断。5. 写在最后的个人体会这次踩坑让我最深的感受是凡是封装良好的库都会在“易用性”和“可控性”之间做选择。fetchEventSource把 SSE 流式处理中繁琐的部分——事件解析、重连、打开关闭回调——全封装了这是它的价值但这也意味着你对底层fetch行为、AbortSignal 语义、甚至是 HTTP 连接生命周期的理解成了你能不能用好它的关键。踩过几次坑之后我现在写流式请求代码时一定会遵循几个铁律每个AbortController只服务一个请求绝不复用onerror里至少要打一行错误日志包含error.name和error.messageonopen回调里记录 HTTP 状态码方便日后判断问题在“服务端”还是“连接”服务端要支持幂等时再开自动重连否则必须在业务层做去重依赖包升级后重新过一遍openWhenHidden、signal、onerror的默认行为是否变化。如果让我对正在做 AI 应用、或者准备做流式渲染的朋友说一句掏心窝的话别急着把所有接口都换到fetchEventSource它不是万能的也别因为一次failed to fetch就退回原生 fetch那个坑更大。先想清楚你的业务究竟需要什么控制粒度再决定用哪把工具。最后再分享一个我在排查任何failed to fetch时的定式先看error.name再看onopen是否执行然后复制 curl 验证服务端最后检查 CORS 预检和 AbortSignal 状态。按这个顺序走目前我还没遇到定位不出来的failed to fetch。希望这篇踩坑记录能帮你少熬一个夜。
返回列表