fetch请求超时怎么办?从AbortController到重试策略的完整指南
你有没有遇到过这种情况一个 fetch 请求发出去后端服务挂了、网络波动了、接口迟迟不返回结果页面就像被冻住一样加载图标转个不停。用户催你“页面是不是坏了”你打开控制台一看请求还挂在那里等了足足 60 秒甚至更久才报错。甚至更离谱的是有些环境里 fetch 请求永远不返回页面就这么一直等下去。这就是 fetch 请求超时问题。我在项目里被它坑过不止一次最惨的一次是某个数据大屏页面一个接口偶发不返回导致整个页面状态卡死后台监控里 timeout 日志刷了上千条。那之后我专门把“如何使 fetch() 请求超时”这个问题彻底研究了一遍也总结出了一套从原理到实践的完整方案。这篇文章就围绕 fetch 超时这个核心话题展开先讲清楚为什么 fetch 默认没有超时机制再给出几种可靠的超时实现方案然后聊聊超时之后怎么优雅处理、怎么和企业级场景联动最后附上我在实战中踩过的坑和排查经验。无论你是刚接触 fetch 的新手还是已经在生产环境里和各种 failed to fetch 报错缠斗了许久的开发者这篇都能给你一些参考。1. 为什么 fetch 默认不内置超时机制很多从 axios 切到 fetch 的开发者都有一个共同的困惑axios 里一个timeout: 5000就能搞定的配置为什么 fetch 里没有我最初也以为是自己没找到参数翻了两遍 MDN 文档才发现fetch 的 RequestInit 里确实没有 timeout 选项。这不是设计疏漏而是故意为之。1.1 fetch 的设计哲学底层接口的克制fetch 的设计定位是“浏览器原生提供的、贴近底层网络能力的接口”。它把连接建立、请求发送、响应读取这些能力暴露给你但把“超时”“重试”“取消”这些更上层的策略交给开发者自己实现。这就像给你一套螺丝刀和扳手但装修方案得自己定。axios 之所以用起来舒服是因为它在 XMLHttpRequest 或者 fetch 之上封装了这些策略本质上是别人替你做了决定。理解了这个定位你就能明白fetch 不给超时参数不是它不行而是它不愿意替你强行做决定。因为“超时时间设多少”这个问题没有标准答案——实时查询接口可能 3 秒就该断批量导出接口可能 30 秒也算正常。把策略留给上层反而更灵活。1.2 没有超时的真实风险没有超时机制最直接的影响就是请求长期挂起。我见过一个线上事故后端服务被慢 SQL 拖垮所有请求都进了等待队列前端页面全部卡在 loading 状态。用户反复刷新请求越积越多最后浏览器层级的连接池也被占满整个站点彻底不可用。那种情况下前端连“请求失败”的提示都弹不出来因为请求根本没失败只是在等。另一个常见的坑是资源泄漏。如果你用 fetch 拉取大文件、流式数据或者轮询接口没有超时控制意味着这些连接会一直占着浏览器资源。在移动端尤其是低端 Android 设备上连接数被占满之后新的请求全部排队页面表现就是越来越卡。所以给 fetch 加超时不是“优化项”而是“必做项”。不管是内部项目还是面向用户的产品只要上了 fetch超时控制应该第一时间考虑。2. 三种超时方案从一行代码到完整工具函数fetch 超时没有现成参数但实现路径其实很清晰通过 AbortController 这个 API 来中断请求。这是目前所有主流方案的基石。下面我按从简到繁的顺序给出三种方案你可以按项目需求选用。2.1 方案一AbortSignal.timeout() 一行搞定如果你只需要“最简单粗暴的请求超时”而且面向的浏览器环境足够新可以直接这样写const res await fetch(https://api.example.com/data, { signal: AbortSignal.timeout(5000) });AbortSignal.timeout()是 2022 年前后逐步被浏览器支持的新 API它会返回一个已经关联好定时器的 signal 对象。超过 5000 毫秒没完成请求fetch 会自动抛出TimeoutErrorDOMException。这是目前代码量最少的写法。但要注意AbortSignal.timeout()在部分旧版浏览器里不可用而且它的超时是“一锤子买卖”——如果你需要超时之后给用户提示、重试、或者做一些清理工作光靠这一行就不够了。它适合那种“超时了直接报错就行”的简单场景。2.2 方案二AbortController setTimeout 手动控制这是目前最通用的方案兼容性最好也最容易理解。核心思路就是一个定时器 AbortController。function fetchWithTimeout(url, options {}, timeout 8000) { const controller new AbortController(); const { signal } controller; const timer setTimeout(() { controller.abort(); }, timeout); return fetch(url, { ...options, signal }) .finally(() clearTimeout(timer)); }用法和平常的 fetch 完全一样try { const res await fetchWithTimeout(https://api.example.com/data, {}, 5000); const data await res.json(); console.log(data); } catch (err) { if (err.name AbortError) { console.log(请求超时了); } else { console.log(其他错误, err); } }这段代码里有几个关键点值得展开。第一finally(() clearTimeout(timer))非常关键。如果请求在超时之前就正常返回了timer 必须被清掉否则它会在后台继续倒计时到点了依然触发controller.abort()。虽然那时请求已经结束abort 不会造成什么影响但定时器会一直占着资源在大量请求的场景下会积累出内存压力。第二signal有个特性同一个 signal 不能被复用。如果你把fetchWithTimeout返回的 Promise 拿去重试重试时还用同一个 signal那第二次请求会直接失败。下面讲重试策略的时候会再细说。第三AbortError的判断不能漏。controller.abort()触发后fetch 抛出的错误名字是AbortError。如果你把超时和普通错误混在一起处理会出现“超时也走了重试逻辑”的诡异问题。2.3 方案三axios 的 timeout 与 fetch 的对照说句公道话如果项目里已经大量使用 axios那直接timeout: 5000就好没必要为了“用 fetch”而用 fetch。但如果你像我一样在某些场景比如浏览器原生环境、Service Worker、部分 Node.js 工具链里必须用 fetch那上面这套手写方案就是你的 timeout 等价物。两种方案的取舍我做个简单对比。对比维度axios timeoutfetch AbortController配置方式直接传 timeout 数字手动创建 signal 定时器超时错误类型timeout 错误码AbortError需自行判断取消请求能力CancelToken / signalsignal兼容性依赖 XHR 环境依赖 AbortController 支持代码量少中等从表格能看出来fetch 方案的劣势是“没有现成的语义化超时错误”必须通过err.name AbortError来判断。这也是我在团队里做分享时反复强调的一点fetch 超时不是“请求失败了”而是“请求被主动中断了”这个语义区别直接影响后面的错误处理策略。3. 超时之后优雅的错误处理与重试策略超时只是开始真正决定用户体验的是超时之后你做了什么。我见过很多项目超时之后直接弹一个“网络错误”用户刷新页面重来体验非常粗糙。下面聊几个我在实践中验证过的处理思路。3.1 判断超时错误与正常业务错误先解决一个新手容易踩的坑fetch 请求超时后抛出的错误和 HTTP 状态码错误不是一回事。服务端返回 500、502、504fetch 本身不会抛错你需要自己检查res.ok或res.status。而真正的“网络层错误”比如 DNS 解析失败、连接被拒绝、以及我们这里讨论的超时中断fetch 才会抛异常。所以处理逻辑最好分层try { const res await fetchWithTimeout(url, {}, 5000); if (!res.ok) { // 业务层错误服务端返回非 2xx if (res.status 504) { // 网关超时可提示“服务处理时间过长” } throw new Error(HTTP ${res.status}); } const data await res.json(); return data; } catch (err) { if (err.name AbortError) { // 前端主动中断请求超时 console.error(请求超时); } else { // 网络错误 / 业务错误 console.error(请求失败, err); } }这样区分之后用户看到的提示也会更准确。超时可以提示“网络较慢请重试”网关超时可以说“服务器繁忙请稍后再试”。3.2 超时之后怎么做重试重试是个双刃剑。不加重试偶发的网络抖动会让用户操作失败加重试如果服务端已经处于过载状态重试只会雪上加霜。我的原则是只对“幂等请求”加重试且必须限制重试次数和间隔。幂等请求就是“执行一次和执行多次结果相同”的请求。GET 请求天然幂等可以放心重试POST 请求要看你后端怎么设计如果同一个请求重复提交会生成多条数据那就坚决不要自动重试。下面是一个带重试的增强版async function fetchWithTimeoutAndRetry(url, options {}, timeout 5000, retries 2) { let lastError; for (let attempt 0; attempt retries; attempt) { try { const res await fetchWithTimeout(url, options, timeout); return res; } catch (err) { lastError err; if (err.name ! AbortError) { // 非超时错误不重试或者按需处理 throw err; } // 指数退避第一次等 1 秒第二次等 2 秒 const delay 1000 * Math.pow(2, attempt); await new Promise((resolve) setTimeout(resolve, delay)); } } throw lastError; }注意这里每次循环都调用了fetchWithTimeout也就是说每一次尝试都会创建新的 AbortController 和 signal。这一点特别重要——我在前面提到过同一个 signal 不能复用如果你把上一次的 signal 传给下一次请求第二次请求会立刻抛错。另外指数退避策略1000 * 2^attempt是我个人的经验值。第 0 次失败后等 1 秒第 1 次失败后等 2 秒最多重试 2 次。间隔太短重试会变成高频轰炸间隔太长用户等得焦虑。如果你们后端有标准的重试间隔要求比如“至少间隔 5 秒才能重试”那就按后端的节奏来。3.3 叠加超时时间总超时与单次超时还有一个容易被忽略的设计点单次请求超时和整个重试过程的总超时。假设你设置了单次超时 5 秒、重试 2 次那理论上最坏情况是 5 1 5 2 5 18 秒。如果产品团队要求“4 秒内给出反馈”这套参数就不合格。在实际项目中我会把“总超时”也用 AbortController 做一层包裹。比如async function fetchWithOverallTimeout(promise, overallTimeout 10000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), overallTimeout); try { // 这里假设 promise 内部也接收了同一个 signal或者用 Promise.race 包一层 return await Promise.race([ promise, new Promise((_, reject) { controller.signal.addEventListener(abort, () { reject(new DOMException(overall timeout, AbortError)); }); }) ]); } finally { clearTimeout(timer); } }组合使用时外层总超时兜底内层单次超时负责重试节奏。这也是我在大型项目中验证过的模式能很好平衡“快速失败”和“尽量成功”两个目标。4. 实战场景联动当 fetch 超时遇上 PDF、OAuth 与 Git理论部分讲完了接下来我把实战中遇到过的一些场景串起来聊。你可能已经注意到最初列出的热搜词里有好几个都带着奇奇怪怪的报错——pdf.js failed to fetch、failed to fetch oauth token、git 拉取代码一直 fetch。这些看似不相关的问题本质上都是同一个主题fetch 请求没有合理的超时控制或者超时后没有合理的处理策略。4.1 PDF 预览场景pdf.js 的 failed to fetch如果你用过 pdf.js 做 PDF 在线预览一定见过这个报错pdf.js v2.16.105 (build: 172ccdbe5) 信息: failed to fetch。这个报错通常出现在 pdf.js 尝试加载 PDF 文件元数据比如附带 xref 表的时候。我排查过一个真实案例客户的 PDF 文件放在对象存储里某些大文件冷启动读取需要 5~8 秒而 pdf.js 内部对某些请求的等待时间很有限超过一定时间就报 failed to fetch。解决方案有两步。第一确认对象存储的响应时间如果确实慢考虑做预加载在用户点开 PDF 之前就把文件拉到浏览器缓存。第二如果无法预加载就覆盖 pdf.js 的 fetching 逻辑给它的内部请求加上更灵活的超时配置。这里给一个简化思路在初始化 pdf.js 时通过pdfjsLib.GlobalWorkerOptions或者传入自定义 fetch 实现把AbortSignal.timeout()加进去。但要注意pdf.js 内部有时会并行发多个请求超时时间设置太短反而会误伤正常加载。4.2 OAuth Token 获取failed to fetch oauth token 的根源另一个高频报错是failed to fetch oauth token。这个通常发生在前端直接调用授权服务器的 token 接口时比如 implicit 模式或 PKCE 模式下换取 token。我的建议是token 请求的超时时间尽量不要设置得太短5 秒是底线推荐 8~10 秒。原因是授权服务器在极端情况下需要做重定向、跨域预检CORS preflight、甚至调用后端回调链路比较长。如果你的 token 请求偶发超时别急着调短超时时间先排查是不是预检请求本身就把时间吃掉了。另外token 请求如果超时了重试要格外小心。因为 token 接口不规范地重试可能造成多个有效 token 同时存在或者刷新 token 被重复使用引发新的鉴权问题。正确做法是超时后跳转到统一的错误页或者引导用户重新走一遍授权流程而不是静默重试。4.3 Git 场景git fetch 卡住与 GC 提示你可能没想到git 的fetch和前端 fetch 在超时这件事上也有共通点。git 拉取代码一直 fetch、see help gc for manual housekeeping这类提示通常不是网络层面超时而是 git 对象库太大、或者本地仓库缺少有效的垃圾回收导致 fetch 阶段处理对象数据时异常缓慢。虽然 git fetch 和 fetch API 完全是两码事但这个报错给我的启发是任何“拉取”动作都需要有超时意识和监控手段。对前端开发者来说学会看网络面板里的请求耗时分布能帮你区分“是服务端处理慢”还是“是前端等待策略有问题”对后端同学来说git fetch 卡住时的 GC 提示说明了长期运行的进程需要定期维护否则性能会持续恶化。4.4 包管理工具的 fetch 超时pip、npm 等上面热词里还有 pip 报错比如cannot fetch index base url http://pypi.python.org/simple/、there was a problem confir...。这类工具内部其实也有超时设置。pip 可以通过--timeout参数指定单次连接超时npm 可以通过配置 fetch-timeout 来控制。如果你在公司内网拉包经常失败优先检查网络代理和镜像源而不是无限调大超时。不过这里想给一个更通用的建议在任何语言、任何工具链里“获取远端资源”这个动作都应该有超时和失败指标。我们做前端监控时会把 fetch 请求纳入性能监测web vitals 里也有专门的首字节时间TTFB指标。关注这些比等到用户报障再排查要高效得多。5. 常见问题与排查技巧实录最后这部分我把自己这几年在 fetch 超时上踩过的坑集中整理一下用问答形式写出来。这些内容大多在官方文档里找不到属于“不踩一遍根本不知道”的类型。5.1 超时时间设多长才合理没有标准答案但有判断依据。先看你的服务端 P95 响应时间——如果 95% 的请求在 800 毫秒内返回那超时设在 3 秒就是舒适的如果你们的接口因为业务原因本身就要跑几秒那超时就该给到 10 秒甚至更多。核心原则是超时时间 正常响应时间上限 × 1.5 到 3 倍留出网络抖动余量。我一般会做一个“分接口超时”的配置表而不是全项目统一一个超时时间。列表类查询 5 秒详情类查询 8 秒批量导出 30 秒文件上传另算。这样做虽然繁琐但能最大化用户体验避免一刀切导致“快接口等太久、慢接口总超时”的尴尬。5.2 为什么我的 AbortSignal.timeout() 没生效如果你用了AbortSignal.timeout()但觉得没生效先排查两件事。第一浏览器版本是否支持Safari 的老版本、部分 WebView 内核里这个 API 可能不存在。第二如果请求已经完成不管成功还是失败signal 的 abort 不再有任何效果这是正常现象不是代码问题。5.3 超时中断请求后服务端还会继续处理吗这是一个很多人忽略的重要问题。controller.abort()中断的是浏览器到服务端的连接服务端如果已经接收到了请求它不会因为前端断开就自动停止处理。这就像你挂了电话但对方还在那边继续说。所以超时后如果用户立刻发起相同请求服务端可能同时在处理两笔相同的任务。解决思路有两个层面前端层面超时后不要盲目重试写操作型请求后端层面对耗时任务设计幂等键同一个幂等键的重复请求直接返回上一次结果或者丢弃后一个。这样即使前端超时重试也不会造成数据重复。5.4 AbortError 和 TimeoutError 的区别是什么AbortSignal.timeout()在超时时会抛TimeoutError这个 DOMException而手动controller.abort()抛的是AbortError。判断逻辑要注意区分catch (err) { if (err.name TimeoutError) { // 由 AbortSignal.timeout 产生的超时 } else if (err.name AbortError) { // 由其他 abort 引起的取消 } }在方案二手动 AbortController里你只能手动触发 abort所以只会出现AbortError。如果你有“区分超时和主动取消”的需求就优先用AbortSignal.timeout()或者在自定义工具里给错误打标记。5.5 上传下载场景的超时怎么处理fetch 的超时设置对上传、下载文件也生效但要注意大文件传输耗时很长不宜用“整个请求超时”来控制。更好的做法是用进度事件做“超过 N 秒没有进度才中断”的闲置超时。浏览器原生 fetch 不支持上传进度事件response.body 可以读下载进度所以如果是大文件上传我建议直接用 XMLHttpRequest 或者成熟的上传库它们对超时和断点续传的处理完善得多。5.6 监控与告警让超时问题提前暴露超时控制做好了还得能发现。我的做法是在封装的 fetch 工具里埋点每次请求把耗时、是否超时、超时来源单次超时还是总超时上报到监控平台。然后用告警规则盯住两个指标超时率和 P95 响应时间。超时率突然上升先看代码最近有没有改动再看服务端监控P95 响应时间持续走高大概率是后端慢 SQL 或者依赖服务变慢。这一步做到了你就从“用户报障后救火”提前到“系统报警后排查”完全是两个体验档次。写在最后一个小经验我在实际项目里踩过最多次的坑就是各种 fetch 超时处理不彻底——要么只设置了单次超时忘了总超时要么超时之后直接重启一个错误弹窗完全没有重试链路的考虑。后来我把方案整理成团队内部的一个工具包要求所有 fetch 请求都走统一封装超时时间按接口类型配置、错误上报自动打点、幂等请求自动重试。最后再分享一个小技巧调试超时逻辑的时候别真等 5 秒、10 秒太浪费时间。你可以先写一个临时的 mock 接口故意延迟 3 秒返回然后把超时时间设成 1 秒确认中断逻辑正确后再把参数调回正常值。这样一轮调试验证下来几分钟就能跑完所有分支。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →