:AbortSignal復(fù)用引發(fā)的failed to fetch排查)
1. 先交代背景我是怎么踩進這個坑的最近在做一個 AI 對話前端改造需要把大模型回答從“等半天一次性吐出來”改成“邊生成邊渲染”的流式效果。需求本身不復(fù)雜但落地時卻讓我在fetchEventSource和原生fetch之間反復(fù)橫跳折騰了整整兩天。最崩潰的一條報錯長這樣failed to fetch dynamically im 無法加載 agent 預(yù)設(shè) client api: agentpresets/list failed: failed to fetch明明上一秒接口還能通換掉請求方式之后水靈靈地就開始failed to fetch而且只有流式相關(guān)接口跪了普通 JSON 接口一切正常。后來我把整套鏈路從瀏覽器到服務(wù)端到 SSL 全查了一遍最后才發(fā)現(xiàn)問題不是網(wǎng)絡(luò)不是網(wǎng)關(guān)而是我跟fetchEventSource之間有層“沒有說透的窗戶紙”。這篇文章不打算寫那種“fetchEventSource 比 fetch 好”的結(jié)論帖而是想把我這次真實的排查過程拆開講清楚兩個東西在流式場景下的本質(zhì)區(qū)別。如果你也在做 SSE 流式輸出、大模型實時渲染或者遇到failed to fetch、agentpresets/list failed、abort被莫名觸發(fā)這類報錯這篇文章里的排查思路和結(jié)論應(yīng)該能幫你少走不少彎路。先說結(jié)論要點原生fetch支持讀流但它只是“給了你水管”fetchEventSource則是一套完整的水泵系統(tǒng)。兩者在流式場景下的區(qū)別主要集中在四個地方——事件解析、斷線重連、請求頭約束、以及中止信號的語義。搞懂這四點幾乎所有流式報錯都能定位。2. 重新認識兩個讀取方式fetchEventSource 與 fetch 的本質(zhì)差別2.1 原生 fetch 的流式是“給了水管但沒給你水泵”原生fetch從很早開始就支持讀取流式響應(yīng)了核心就三個 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 里的數(shù)據(jù) // 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 協(xié)議規(guī)定事件之間用空行分隔但網(wǎng)絡(luò)分包可能把一個事件切成兩半你得自己維護 buffer如果服務(wù)端發(fā)了注釋行以:開頭的行用于心跳?;钅愕米约禾^斷線了不會自動重連得自己寫重試邏輯請求頭雖然隨便你加但服務(wù)端 CORS 是否會暴露、預(yù)檢請求能否通過依然要自己處理。這些“自己來”的部分看著都不難但疊加在一起就是典型的多處邏輯交織、邊界問題頻發(fā)的狀態(tài)。我第一次用原生 fetch 寫流式60 行代碼里有 30 行都在處理字符串切分和異常兜底。2.2 fetchEventSource可以理解為“配備了泵、閥門和儀表盤的成套方案”fetchEventSource是微軟出的一個庫本質(zhì)是在fetch之上做了一層封裝專門針對 SSE 流式場景。它解決的核心痛點是EventSource天然只支持 GET不能用 POST 傳業(yè)務(wù)參數(shù)也不能自定義請求頭比如帶上 Authorization 令牌而 AI 對話類接口幾乎都是 POST JSON 鑒權(quán)頭。fetchEventSource用fetch重新實現(xiàn)了 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(連接建立狀態(tài)碼, response.status); } else { throw new Error(HTTP ${response.status}); } }, onmessage(event) { const data JSON.parse(event.data); renderContent(data.content); }, onclose() { console.log(流正常關(guān)閉); }, onerror(error) { console.error(流異常嘗試重連, error); // 返回非 void 時庫會自動重連 // 如果不想重連可以 throw error }, });這套 API 看起來清爽多了。onmessage幫你把 SSE 的data:行解析好event.data直接就是內(nèi)容onerror里返回一個值庫會幫你做自動重連連接建立、打開、失敗、關(guān)閉全都有回調(diào)鉤子。還有openWhenHidden這個參數(shù)處理了頁面切換 Tab 時瀏覽器對連接的限制原生 EventSource 在這個場景下有個痛點是隱藏頁面會掛起這庫能繞開。用一句生活化的話說原生 fetch 給你一根水管讓你自己裝水泵、裝水表、裝閥門fetchEventSource直接交付一套集成好的供水系統(tǒng)你只需要打開龍頭。2.3 為什么說“自定義 header”是分水嶺很多人在選型時糾結(jié)“為什么不用 EventSource它不也是 SSE 嗎”——這就是沒踩過真實需求場景才會有的疑問。原生EventSource的問題非常致命它不能帶自定義請求頭。你拿它調(diào)一個需要Authorization的私有化模型接口直接就是 401 甚至跨域預(yù)檢失敗?,F(xiàn)在不少 LLM 網(wǎng)關(guān)還要求請求里帶api-key、trace-id這玩意兒根本塞不進去。所以這時候有兩類選擇用原生fetch自己解析流請求頭管夠代價是解析邏輯、斷線重連、心跳保護全手寫用fetchEventSource它內(nèi)部用 fetch 實現(xiàn)自定義 header、POST body 都支持同時把 SSE 協(xié)議的上層語義補齊。我在這次改造里選的是fetchEventSource理由很簡單——我們的服務(wù)端網(wǎng)關(guān)要求每個流請求都帶內(nèi)部api-key和request-id原生 EventSource 直接出局而項目里又要求快速交付手寫解析器的維護成本不低用一個成熟封裝更穩(wěn)妥。但正是這次選型讓一個問題暴露出來fetchEventSource太好用了以至于讓我忽略了它內(nèi)部對“錯誤響應(yīng)”和“連接中止”有一套自己的處理邏輯而這套邏輯在某些場景下和原生fetch的語義完全不一致。3. 真實踩坑過程一次 agent 預(yù)設(shè)加載失敗引發(fā)的排查3.1 現(xiàn)象復(fù)盤先還原一下我當(dāng)時的場景。項目里有一個“智能體預(yù)設(shè)列表”的接口/api/agentpresets/list用來給對話頁加載可選的 AI 角色。這不是個大模型流式接口而是普通參數(shù)列表接口。但當(dāng)時前端統(tǒng)一把這類接口從fetch切換成了fetchEventSource——因為我天真地以為“既然都是走 HTTP統(tǒng)一封裝組件最省事”。切換之后一連串接口開始報錯failed to fetch 無法加載 agent 預(yù)設(shè) client api: agentpresets/list failed: failed to fetch注意最后一次報錯這是瀏覽器終端的原始信息翻譯過來就是fetch在請求還沒有拿到任何響應(yīng)頭之前連接就被中止了。因為fetchEventSource的onopen回調(diào)只有在收到響應(yīng)頭之后才會觸發(fā)而這次請求連這一步都沒走到。我第一反應(yīng)是服務(wù)端掛了。于是用 Postman 直接打同一個接口200秒回數(shù)據(jù)完整。再用 curl 打200一切正常。那么問題就出在前端請求本身。3.2 排查鏈路我按下面這個順序排除寫下來給同樣踩坑的人參考第一層看請求是否真正發(fā)出。打開 DevTools 的 Network 面板在agentpresets/list請求上右鍵復(fù)制為 curl命令行跑一遍。如果 curl 能通說明服務(wù)端、網(wǎng)關(guān)、SSL 都沒問題。剩下的問題集中在瀏覽器環(huán)境和請求庫。第二層查 CORS 和預(yù)檢。我們的接口帶Authorization和api-key自定義頭瀏覽器會先發(fā)一個OPTIONS預(yù)檢請求???Network 面板里是否有預(yù)檢請求預(yù)檢是否返回了正確的Access-Control-Allow-Headers這一步很關(guān)鍵因為fetchEventSource內(nèi)部即使設(shè)置了 headers如果服務(wù)端沒放行這些自定義頭請求在預(yù)檢階段就被瀏覽器攔截了表現(xiàn)就是failed to fetch。這個坑非常經(jīng)典尤其是從 Postman 測不出問題的情況下十有八九卡在這。第三層查代理層和網(wǎng)關(guān)是否對流式請求做了特殊處理。我們服務(wù)端有個 Nginx 網(wǎng)關(guān)檢查proxy_read_timeout、proxy_buffering這類配置。如果proxy_buffering開著SSE 流的響應(yīng)會被 Nginx 攢著不吐客戶端遲遲收不到第一個字節(jié)容易觸發(fā)表層超時。雖然這里報的是“預(yù)設(shè)列表”接口但網(wǎng)關(guān)是統(tǒng)一入口配置影響所有接口。第四層查 AbortController 與頁面生命周期。我們的對話頁在組件卸載時會調(diào)用ctrl.abort()取消未完成的流式請求。如果請求時序上組件先卸載、請求后返回那么 abort 信號會導(dǎo)致 fetch 以AbortError結(jié)束最終同樣表現(xiàn)為failed to fetch。這個在所有異步請求中都可能發(fā)生屬于經(jīng)典競態(tài)。四層查完前三層都沒問題第四層嫌疑最大。于是我打開 Network 面板盯著預(yù)設(shè)列表請求的 timing發(fā)現(xiàn) Grunt 一個巧合這個接口發(fā)出的時機和上一個流式請求 abort 的時機幾乎重疊。3.3 根因定位到這里真相就比較清晰了。我們的對話頁切換 agent 預(yù)設(shè)時會先abort()上一個流式請求再發(fā)起新的預(yù)設(shè)列表請求。而fetchEventSource有個重要特性它內(nèi)部維護的是同一個AbortSignal信號鏈。如果你在fetchEventSource的選項里傳入某個signal它內(nèi)部的所有重連嘗試都會復(fù)用這個信號。問題出在我沒有為每次請求創(chuàng)建獨立的AbortController而是模板里復(fù)用了同一個。第一次請求 abort 后這個 controller 的 signal 狀態(tài)變成了aborted接下來所有復(fù)用這個 signal 的請求fetch 都會立即拒絕根本不會發(fā)出網(wǎng)絡(luò)請求。換句話說fetchEventSource的signal一旦 abort 就永久失效它是“一次性信號”。而原生fetch遇到同樣的情況也一樣是被 abort 拉住——這不算 fetchEventSource 獨有的問題但因為fetchEventSource內(nèi)部消息循環(huán)和重連機制的存在這種“被 abort 拒絕”的請求其錯誤信息里沒有明確的AbortError標(biāo)記而是被轉(zhuǎn)換成了TypeError: Failed to fetch所以排查時很容易誤判成網(wǎng)絡(luò)問題。再進一步看為什么這個報錯會串到agentpresets/list這樣完全無關(guān)的接口上就是因為我把同一個AbortController傳給了所有接口請求。表面上代碼是這個樣子的// 錯誤示范所有請求復(fù)用一個 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) { /* 處理 */ }, }); }一旦某個環(huán)節(jié)調(diào)用了sharedController.abort()后面再發(fā)的任何復(fù)用請求都不再有意義。這不是fetchEventSource的問題而是我對“AbortSignal 是一次性狀態(tài)”這個底層語義理解不到位——所以我在標(biāo)題里強調(diào)這是一次“本質(zhì)區(qū)別”本質(zhì)不是 API 長什么樣而是狀態(tài)語義。修復(fù)辦法非常簡單每次請求都 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) { /* 處理 */ }, }); }如果你真的需要在某個頁面級別統(tǒng)一取消所有請求也建議維護一個 controller 集合而不是共用一個AbortController。每次請求創(chuàng)建獨立 controller頁面卸載時統(tǒng)一調(diào)用集合里的abort()。注意AbortSignal一旦進入 aborted 狀態(tài)是無法恢復(fù)的。你沒法把同一個 signal 取消后再復(fù)用。這是 web 平臺的固定語義跟庫無關(guān)。4. 避坑經(jīng)驗與報錯速查表4.1 選型建議什么時候用 fetchEventSource什么時候用原生 fetch這次踩坑之后我把“流式讀取”的選型標(biāo)準(zhǔn)重新梳理了一遍。沒有哪個方式是絕對正確的只有更適合你當(dāng)前場景的。場景推薦方式原因大模型對話需要 POST 自定義 header SSEfetchEventSource自動解析事件、自動重連、支持 POST 和 header后端就是標(biāo)準(zhǔn) GET SSE比如某些開源消息推送原生EventSource瀏覽器原生能力不需要引庫天然支持自動重連只需要非常輕量的單次響應(yīng)讀取不關(guān)心重連原生fetchReadableStream依賴少代碼可控涉及復(fù)雜的多遍流處理、事件類型多樣、需要精細控制每類事件fetchEventSource它的onopen/onmessage/onerror/onclose鉤子比原生fetch的裸流處理清晰得多項目對依賴包體積極其敏感原生fetch少一個運行時依賴打包體積自然減小我個人的經(jīng)驗是如果你在做 AI 對話類功能第一選擇就是fetchEventSource。它讓你把精力花在業(yè)務(wù)邏輯上不用每次糾結(jié)字符串切拆和心跳處理。但代價是——它是個封裝層你踩的坑往往不是它本身不夠好而是你沒搞懂它背后依賴的底層語義比如 AbortSignal 的一次性特性、自動重連可能帶來的重復(fù)數(shù)據(jù)問題。4.2 常見報錯速查表把這次項目里遇到的和網(wǎng)上高頻出現(xiàn)的問題整理成了一張速查表按failed to fetch相關(guān)錯誤類型和排查路徑給出來報錯關(guān)鍵詞可能原因排查順序failed to fetch跨域預(yù)檢失敗、服務(wù)端未響應(yīng)、連接被 abort、網(wǎng)關(guān) buffer1. Network 復(fù)制 curl 驗證服務(wù)端 2. 檢查 OPTIONS 預(yù)檢 3. 檢查 body 是否被 abortfailed to fetch dynamically只用于動態(tài)導(dǎo)入和運行時 fetch 無直接關(guān)系但報錯前常伴隨網(wǎng)絡(luò)不可達或單頁應(yīng)用資源加載失敗檢查靜態(tài)資源 CDN 可達性、路由目錄是否正確agentpresets/list failed: failed to fetch請求被 AbortSignal 攔截、或者自定義 header 未通過 CORS重點查 AbortController 是否被復(fù)用、預(yù)檢響應(yīng)頭connect econnrefused服務(wù)端端口未監(jiān)聽、防火墻攔截、服務(wù)未啟動curl -v看握手過程檢查服務(wù)日志failed to fetch version from claude.ai這是某些工具在檢測網(wǎng)絡(luò)或版本源時的通用錯誤多數(shù)和代理/證書/網(wǎng)絡(luò)隔離相關(guān)換網(wǎng)絡(luò)源看是否能通檢查系統(tǒng)代理設(shè)置git fetch或git pull很慢緩沖區(qū)容量、協(xié)議差異、DNS 解析慢git config --global http.postBuffer調(diào)大檢查https.sslVerifyVS Code 服務(wù)器failed to fetch遠程環(huán)境下載 server 包失敗手動下載vscode-server-linux-x64.tar.gz放到指定目錄這里必須強調(diào)failed to fetch是前端最常見但又最沒有信息量的錯誤。小技巧是在onerror回調(diào)里加一層錯誤轉(zhuǎn)換把error.name和error.message都打出來。如果error.name AbortError說明是主動中止如果error.message含NetworkError說明是連接層面的問題如果是TypeError: Failed to fetch但實際請求沒有發(fā)出大概率是 CORS 或 signal 問題。這一手能在你上 DevTools 之前先快速縮小范圍。另外一個小技巧如果你需要排查“請求到底有沒有發(fā)到服務(wù)器”可以在組件里臨時給fetchEventSource加一個onopen回調(diào)onopen(response) { console.log(HTTP 狀態(tài), response.status, 說明服務(wù)端已收到請求); }只要onopen執(zhí)行了說明服務(wù)端已返回響應(yīng)頭問題不在“服務(wù)端沒收到請求”。如果onopen一直不執(zhí)行那就是請求沒到服務(wù)端優(yōu)先查 CORS、DNS、證書、signal。這個“響應(yīng)頭是否返回”的判斷思路能把你從“服務(wù)端到底通沒通”的泥潭里拉出來。4.3 一個額外的坑重連造成的重復(fù)數(shù)據(jù)除了 AbortSignal 的坑fetchEventSource自動重連機制還會帶來另一個問題斷線重連后消息可能重復(fù)。比如你調(diào)大模型接口流式返回了一部分內(nèi)容后網(wǎng)絡(luò)閃斷fetchEventSource會自動重連并重新發(fā)送請求。如果服務(wù)端沒有做“斷點續(xù)傳”或者“請求去重”那前端就會再次收到從第一條開始的內(nèi)容界面上就出現(xiàn)了重復(fù)的渲染。我當(dāng)時調(diào)的是一個內(nèi)部 LLM 網(wǎng)關(guān)網(wǎng)關(guān)并不緩存歷史輸出重連后從零開始生成前端渲染里就出現(xiàn)了兩遍回答拼接的詭異效果。這類問題的處理思路有兩個方向前端做消息冪等靠event.id或遞增序號重復(fù)內(nèi)容直接丟棄重連后讓用戶手動確認“是否繼續(xù)上次回答”而不是無感重放。對于 AI 對話這種場景自動重連不總是好事。服務(wù)端生成狀態(tài)已經(jīng)在第一輪請求里消耗過一遍了重連不是在“繼續(xù)生成”而是在“重新生成”此時自動重連反而制造混亂。所以我后來把onerror改成了手動控制onerror(err) { // 打印原始錯誤 console.error(流產(chǎn)生錯誤, err.name, err.message); // 如果是 AbortError說明是用戶/組件主動中止不重連 if (err.name AbortError) { throw err; } // 其他錯誤默認自動重連這里不返回具體值即可 // 如果你希望手動控制直接 throw 出去 throw err; }實際項目里我會區(qū)分錯誤類型來決策是否重連。主動 abort 的重試毫無意義網(wǎng)絡(luò)抖動且服務(wù)端支持冪等時自動重連才值得開。這樣的決策能力是裸fetch和fetchEventSource都很難替你做主的都需要你對業(yè)務(wù)流有清晰判斷。5. 寫在最后的個人體會這次踩坑讓我最深的感受是凡是封裝良好的庫都會在“易用性”和“可控性”之間做選擇。fetchEventSource把 SSE 流式處理中繁瑣的部分——事件解析、重連、打開關(guān)閉回調(diào)——全封裝了這是它的價值但這也意味著你對底層fetch行為、AbortSignal 語義、甚至是 HTTP 連接生命周期的理解成了你能不能用好它的關(guān)鍵。踩過幾次坑之后我現(xiàn)在寫流式請求代碼時一定會遵循幾個鐵律每個AbortController只服務(wù)一個請求絕不復(fù)用onerror里至少要打一行錯誤日志包含error.name和error.messageonopen回調(diào)里記錄 HTTP 狀態(tài)碼方便日后判斷問題在“服務(wù)端”還是“連接”服務(wù)端要支持冪等時再開自動重連否則必須在業(yè)務(wù)層做去重依賴包升級后重新過一遍openWhenHidden、signal、onerror的默認行為是否變化。如果讓我對正在做 AI 應(yīng)用、或者準(zhǔn)備做流式渲染的朋友說一句掏心窩的話別急著把所有接口都換到fetchEventSource它不是萬能的也別因為一次failed to fetch就退回原生 fetch那個坑更大。先想清楚你的業(yè)務(wù)究竟需要什么控制粒度再決定用哪把工具。最后再分享一個我在排查任何failed to fetch時的定式先看error.name再看onopen是否執(zhí)行然后復(fù)制 curl 驗證服務(wù)端最后檢查 CORS 預(yù)檢和 AbortSignal 狀態(tài)。按這個順序走目前我還沒遇到定位不出來的failed to fetch。希望這篇踩坑記錄能幫你少熬一個夜。