ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Failed to fetch 与 404 排查:CORS 预检、代理与路径拼接

Failed to fetch 与 404 排查:CORS 预检、代理与路径拼接 前端控制台里蹦出一行红字TypeError: Failed to fetch旁边还跟着/api/user/profile 404 Not Found这两句话摆在一起很多人第一反应是接口挂了或者网络断了然后开始怀疑后端服务、怀疑运营商、怀疑本地环境。实际情况往往比这简单得多也琐碎得多——绝大多数时候它是某处路径被拼错了一个斜杠、某条反向代理规则把前缀吃掉了、或者浏览器发的是预检请求而后端根本没给它留位置。我自己排查这类问题不下几十次从最早的重启大法到后来养成一套固定的剥洋葱流程中间踩的坑足够写一本小册子。这篇内容围绕Failed to fetch与404 Not Found这两类报错展开目标是把看到报错到定位根因之间的那段模糊地带填满。适合前端、后端、运维以及需要联调第三方接口的同学看无论你是刚接触跨域配置的新手还是已经能背出 CORS 响应头字段的老手下面这套拆解方式都能直接拿去用。整篇不绕弯子重点讲清三件事这两个报错各自意味着什么、怎么用最小成本把它们拆开、以及怎么让同类问题以后不再重复出现。1. 先把 Failed to fetch 和 404 Not Found 当成两件事看1.1 浏览器报 Failed to fetch 时它其实什么都没告诉你fetch这个 API 在设计上有一个非常不友好的特性几乎所有底层失败都统一抛出一个TypeError消息就是那句干巴巴的Failed to fetch。它触发的原因包括 DNS 解析失败、TCP 连接被拒绝、TLS 握手失败、请求被AbortController主动取消、页面是 HTTPS 却请求了 HTTP 资源混合内容拦截、以及最最常见的 CORS 预检不通过。这些原因在能力层面上天差地别但浏览器给到 JavaScript 层的信息量几乎为零。这不是浏览器偷懒而是安全模型决定的——如果 JS 能读到端口不通和域名不存在的区分就等于给了探测内网结构的抓手。所以策略层的信息被刻意抹掉了只留下一个笼统的失败。1.2 404 反而是个好消息因为服务器确实回答了你这里有个极其重要的认知差HTTP 404 意味着请求成功到达了服务器并且服务器返回了一个结构完整的响应只是这个响应告诉你你要的东西我这没有。也就是说网络层、DNS、TLS、连接建立这一整条链路全部是通的。正因为如此404 永远不会触发Failed to fetch。如果你在同一个请求上同时看到这两者那要么是两次不同的请求要么是某个封装库把状态码错误重新包装成了网络错误的文案。把这个区分记牢排查效率会直接翻倍看到Failed to fetch就往网络层和策略层查看到 404 就往路由层和资源映射查两边的工具箱完全不一样。1.3 404 至少有三副面孔别见到就往一个方向想同样是 404来源可能完全不同处理方式也完全不同404 来源典型特征排查入口业务服务器响应体是自家格式的 JSON带detail、code等字段后端路由表、接口版本反向代理或网关响应头Server是 nginx 之类响应体是默认 HTML 错误页location 匹配规则、rewrite 配置应用框架兜底框架统一拦截返回 404用于掩盖权限不足框架异常处理器、鉴权中间件区分方法很简单打开 Network 面板看 Response Headers 里的Server、Content-Type再看响应体的长相。响应体是 HTML 骨架、没有业务字段的基本可以断定请求连业务进程都没进去问题出在网关或静态资源服务上。响应体是规范 JSON 的说明业务代码执行了只是没有匹配到对应资源。1.4 还有一个容易被忽略的中间态请求发出去之前就被砍了有些情况下请求根本没到网络层。比如地址里带了非法字符、使用了file://协议、请求体序列化失败、或者上游的拦截器在request阶段直接抛错。这类问题的表现也是Failed to fetch或者一个语焉不详的错误对象但打开 Network 面板会发现压根没有这条请求记录。没有记录就等于没发出去那就不用往服务器方向查了直接看自己这边的代码。2. Network 面板里那几列能回答的问题比你想的多2.1 Request URL 是排查的起点不是终点面板里的 Request URL 显示的是浏览器实际请求的绝对地址它和你在代码里写的相对路径之间隔着好几层加工环境变量注入的 baseURL、前端路由的 basename、开发服务器的代理配置、以及 URL 构造函数对斜杠的规范化处理。我遇到过最典型的一幕代码里写axios.get(/api/list)baseURL 是/gateway开发者以为最终请求是/gateway/api/list结果面板里显示的是http://localhost:3000/api/list——因为开发服务器的代理规则把/gateway前缀rewrite掉了。两边都觉得自己没错实际请求却是个不存在的地址。操作建议直接右键那条失败的请求选择 Copy as cURL粘到终端里跑一遍。这一步的价值在于它把浏览器所有隐式加工的结果摊开给你看了URL、请求头、Cookie 一个不少。2.2 Status 列出现的(failed)和(canceled)必须区别对待(failed)通常意味着连接层出了问题常见于服务没启动、端口写错、证书不受信任。(canceled)则大概率是自己这边的代码主动中断了请求比如组件卸载时触发的AbortController.abort()或者同一请求被重复发起后前一个被取消。这两种状态在业务上都会表现为数据加载失败但修复方式完全相反前者要去查服务端是否活着后者要去看前端有没有重复请求或不当的取消逻辑。我曾经花过两个小时追一个接口偶发失败最后发现是 React 严格模式下开发环境的双次渲染导致第一个请求被取消生产环境反而完全正常。2.3 Response Headers 里藏着请求经过了哪些中间人Server、X-Powered-By、Via、X-Cache这些头能告诉你请求穿过了几层。如果Server是 nginx而你的业务服务是 Node 或者 Java那就说明请求停在了网关层压根没转发到后端进程。还有个更隐蔽的信号如果一条明显是跨域的请求Response Headers 里完全没有Access-Control-Allow-Origin那多半是网关层直接把响应返回了没有走到会添加 CORS 头的应用层。这也是为什么有些项目在本地开发一切正常一上生产就整片接口报跨域因为开发服务器帮忙加了头生产的网关没配。2.4 Timing 面板能区分太快和太慢请求耗时只有几毫秒说明请求根本没出本地或者被立即拒绝耗时正好卡在某个整数值比如 30 秒、60 秒大概率是超时配置在起作用耗时几秒以上才返回 404则可能是网关做了上游健康检查或重试。这个细节对判断是配置问题还是服务宕机很有帮助。几十毫秒返回的 404 几乎必然是路由或代理层面的静态判断真正查过数据库再返回没找到的请求耗时通常不会这么短。3. 用命令行把浏览器这层变量摘出去3.1 先跑一条最朴素的 curl拿到完整地址后第一件事是在终端里复现curl -i -X GET http://localhost:8080/api/user/profile?uid1001-i参数让你同时看到响应头和响应体。这一步能立刻回答一个关键问题问题是不是只存在于浏览器环境。如果 curl 返回 200 而浏览器报 404那说明差异不在 URL 本身而在浏览器额外附加的东西——请求头、Cookie、或者预检机制。3.2 加上浏览器特有的头逼近真实请求把 Network 面板里 Copy as cURL 拿到的那条命令直接跑一遍然后再手动删掉几个头跑一遍用二分法找出是哪个头导致了差异# 带上自定义头会触发预检 curl -i -H X-Requested-With: XMLHttpRequest -H Content-Type: application/json \ -X POST http://localhost:8080/api/order/create -d {sku:A01} # 去掉自定义头退化成简单请求 curl -i -X POST http://localhost:8080/api/order/create \ -H Content-Type: application/x-www-form-urlencoded -d skuA01如果第二条能通、第一条 404 或报错问题基本锁定在预检环节方向立刻从路径写错了切换到OPTIONS 请求没被正确处理。3.3 curl 通了但浏览器不通说明差异在浏览器替你做的事浏览器比 curl 多做几件事自动带上 Cookie、自动发起预检、强制同源策略、拒绝混合内容、对某些头做安全限制。这几件事里能同时产生Failed to fetch和 404 观感的只有预检和凭据。判断凭据问题有个小技巧在 Network 面板里对比预检请求和实际请求的 Cookie 头。如果预检带上了 Cookie 而实际请求没有或者反过来那就是credentials配置和Access-Control-Allow-Credentials没对齐。提醒能读到错误体内容的时候说明请求已经成功返回。如果 JavaScript 里连error.response都拿不到那问题一定在网络层或策略层直接跳过业务代码看配置。4. 路径拼接404 里出现频率最高的一类根因4.1 baseURL 的斜杠问题比想象中更容易翻车前端请求库拼接 URL 的规则经常和人的直觉不一致。以常见的 axios 为例baseURL和url的拼接基本是按字符串直接相连的baseURL请求路径实际结果是否符合预期/api/user/list/api/user/list是/api//user/list/api//user/list否可能被网关拒/api/user/list/api/user/list是/apiuser/list/apiuser/list否路径直接粘一起了/api./user/list/api/./user/list否出现多余段落注意第三行和第四行的对比——同一个 baseURL路径前面加不加斜杠结果完全不同。很多网关对连续斜杠或者.段落的处理并不宽容一部分会直接返回 404另一部分会做规范化但规范化后的地址和你的路由不匹配照样 404。4.2 反向代理把前缀吃掉是前后端最容易互相甩锅的地方典型场景前端统一在 baseURL 里带一个服务前缀比如/gatewaynginx 里配了location /gateway/ { proxy_pass http://backend/; }。注意proxy_pass结尾那个斜杠——有它的情况下nginx 会把匹配到的/gateway/前缀替换掉转发过去的是/api/user/list。没有那个斜杠转发过去的就是/gateway/api/user/list。后端看到的是后者路由表里只有前者于是 404。这类问题最坑的地方在于开发环境和生产的代理配置经常不是一套。本地开发服务器用 JS 写的代理规则生产用 nginx两边的前缀处理语义不同本地通、线上崩就成了常态。排查手法是固定套路在 nginx 里临时加一行日志把$request_uri和转发后的地址都打出来对比一下就知道前缀去哪了。location /gateway/ { proxy_pass http://backend/; add_header X-Debug-Upstream $upstream_addr always; access_log /var/log/nginx/gateway_debug.log; }4.3 末尾斜杠和大小写是最容易被忽略的两个字有些框架的路由是严格区分末尾斜杠的/api/user和/api/user/可能是两个不同的路由项注册了前者请求了后者就是 404。RESTful 风格的服务尤其容易在这里翻车。大小写同理。绝大多数后端框架路由匹配是大小写敏感的而前端在变量命名、路径常量里改个大小写太容易了。/api/UserProfile和/api/userprofile在后端眼里是两个世界。再往外一层静态资源的路径还要考虑部署目录。构建产物放在/var/www/app/distnginx 的root指向/var/www/app那访问路径就得多一层dist少了这一层就是 404而且返回的是 nginx 默认的错误页非常好认。4.4 请求方法不匹配也会表现成 404这个反直觉但确实存在。有些框架的 404 返回发生在路由层它先按方法分组GET /api/order存在但POST /api/order没注册某些实现会返回 405另一些实现尤其是写法比较宽松的直接返回 404。如果你发现路径明明在就是打不通先把请求方法确认一遍别急着怀疑路径。5. 跨域预检失败为什么看起来像网络故障5.1 OPTIONS 请求的三种典型结果浏览器发起跨域请求前会先用 OPTIONS 方法问一句我能这么请求吗。这个预检本身不需要你写代码但它需要服务端配合。它的结果大致有三类返回 204 且带着完整的 CORS 响应头预检通过实际请求继续。返回 404 或 405服务端路由里没有 OPTIONS 的处理项预检失败。表现是 Network 面板里能看见 OPTIONS 请求返回 404而实际请求压根没发出去JS 层拿到的是Failed to fetch。没有响应头预检状态码正常但缺Access-Control-Allow-Origin一样拦。第二类就是404 和 Failed to fetch 同时出现的经典成因。很多框架的跨域中间件是在路由匹配之后执行的OPTIONS 请求匹配不到任何路由直接 404 了中间件根本没机会加头。解决办法通常是让跨域中间件注册在路由之前或者显式注册一个 OPTIONS 兜底路由。5.2 凭据模式与通配符不能共存这个坑说起来简单但报错信息完全不指向它。规范规定当请求带了凭据Cookie、HTTP 认证信息时Access-Control-Allow-Origin不能是*必须回显具体来源。实际表现是不带 Cookie 时接口一切正常一旦credentials设成include立刻变成拦不下来的失败。而且浏览器控制台有时候给的信息很模糊看起来就像网络抖动。正确的服务端写法是回显来源并且带上 Vary 头避免缓存串味// 服务端跨域响应示例 res.setHeader(Access-Control-Allow-Origin, req.headers.origin || ); res.setHeader(Access-Control-Allow-Credentials, true); res.setHeader(Vary, Origin); res.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization, X-Trace-Id); res.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS);注意Access-Control-Allow-Headers必须覆盖前端实际要发的每一个自定义头。少一个预检就失败而且失败信息通常只说头不被允许不会告诉你缺的是哪个。5.3 简单请求和复杂请求的边界决定了你会不会遇到预检一个请求是否触发预检取决于方法和头。GET、HEAD、POST 且Content-Type限定在三种简单类型内、没有自定义头的属于简单请求不预检一旦加了Authorization、X-Trace-Id这类头或者方法变成 PUT、DELETE就升级为需要预检的请求。这个边界的实际意义是同一个接口换种调用方式可能突然从正常变成报错。有人加了一个追踪头用于排查结果整片接口开始报跨域回头删掉头又好了。搞清这条边界能省掉很多无谓的怀疑。注意预检结果会被浏览器缓存缓存时长由Access-Control-Max-Age决定。改了服务端配置后如果没生效别急着否定配置先把缓存清掉或者换个端口再试。6. 部署形态带来的 404路由模式、产物路径与环境注入6.1 history 模式下刷新页面就 404是最经典的一课单页应用如果用 history 路由路径里没有#本地开发服务器通常自带回退到index.html的逻辑所以一切正常。部署到静态服务器后用户在前端路由页面上按 F5浏览器会拿这个前端路径去请求服务器服务器上当然没有/user/profile这个文件直接 404。这不是前端路由的 bug是静态服务器不知道所有非文件路径都应该交给 index.html。nginx 里的标准写法是location / { try_files $uri $uri/ /index.html; }try_files会依次尝试真实存在的文件、同名目录、最后回退到index.html。注意这个回退规则不能加到 API 路径上否则接口 404 也会被塞一份 HTML 回去前端解析 JSON 时又会报一串莫名其妙的错误。所以 API 的 location 必须写在前面并且带上^~或精确匹配来抢占优先级。6.2 构建产物的路径配置决定了资源能不能被找到构建工具里的publicPath不同工具叫法不同也有叫base的决定了两件事资源引用时前面加什么前缀、路由跳转时以什么为基准。部署在子目录下的项目最容易出问题。比如应用放在https://example.com/tools/下publicPath如果还是/页面上引用的 JS 会去https://example.com/assets/index.js找而文件实际在https://example.com/tools/assets/index.js结果就是一片 404页面白屏。判断方法很直接打开 Network 面板刷新页面看那些 404 的资源 URL 前缀对不对。前缀错了就是publicPath的问题前缀对了但后缀错了就是文件名哈希或构建产物没上传的问题。6.3 环境变量是在构建时烧进去的不是在运行时读的这是新手最容易误解的一点。前端项目里通过构建工具注入的环境变量实际上是在打包那一刻被替换成字面量的。这意味着同一份构建产物换个环境部署接口地址不会跟着变。改完环境变量文件不重新构建页面上完全看不到变化。运行时注入的方案比如服务端生成配置脚本、或者先用请求拉一份配置和构建时注入是两套东西不能混着理解。这个认知直接关系到 404 的排查如果你改了环境变量里的接口前缀本地看着生效了因为本地是热更新重新编译部署到线上却没变因为用的是旧产物就会出现本地通线上 404的诡异现象。确认线上产物的构建时间和环境变量值这一步经常被人跳过。7. 错误体里的那段 JSON信息量比状态码大得多7.1{detail:not found}这类响应怎么读后端返回这种结构化错误体说明请求已经进入业务框架是框架的异常处理器生成的。这时候需要关注的不是404本身而是错误体里有没有更细的字段比如path、method、traceId。拿到traceId之后直接去日志系统里搜这个 ID能看到完整的调用链定位速度比盲猜快一个数量级。养成读错误体、抓 traceId 的习惯是从会调接口到会排查问题的分水岭。另外要注意错误体的结构在不同层之间可能不一致。网关返回的错误体通常是固定格式带timestamp、path、status业务框架返回的是自家格式带code、message。看错误体的字段名就能判断是谁返回的这个判断能直接砍掉一半的排查范围。7.2 403 和 404 混用的时候别只盯着状态码有一类设计是故意把无权访问伪装成资源不存在目的是不泄露资源是否存在。这在安全上说得通但给排查制造了巨大障碍——你以为是路径写错了其实是当前身份没有访问权限。区分方法用不同权限的账号各请求一次。如果高权限账号能拿到数据、低权限账号报 404那就是权限伪装不是路径问题。或者查一下接口的鉴权配置看有没有对未授权情况配置成 404 的策略。还有一种情况是状态码被中间层改写。比如网关配置了统一的错误页把某些上游的 403 改写成 404 返回响应头里的Server会暴露这个改写行为。7.3 本地转发层导致的 404要学会看转发目标现在很多项目在本地跑一个轻量的转发服务把前端的请求按规则转到不同后端。这类服务如果规则写错、目标地址没配、或者启动顺序不对表现就是整片接口 404而且错误体风格和平时完全不同。排查时重点看两件事转发服务的启动日志里有没有打印出实际转发目标以及请求经过转发后 URL 变成了什么。很多转发工具支持打开详细日志把日志级别调到 debug转发的每一步都会打出来比在浏览器里猜快得多。如果转发服务的配置文件里用了环境变量占位符还要确认这些变量在启动时真的被读到了。变量没读到、占位符变成空字符串转发目标就退化成一个不存在的地址返回 404 完全合理。8. 一套可以照着走的排查顺序以及让问题不再复发的做法8.1 五分钟定位清单按下面这个顺序走绝大多数情况在第三步之前就能定位看 Network 面板有没有这条请求记录。没有记录说明请求没发出去去查前端代码里的 URL 构造和拦截器。有记录就看状态码。(failed)查网络层和服务存活404 查路由和代理403 查权限。右键 Copy as cURL在终端跑一遍。通了说明差异在浏览器附加行为Cookie、预检、头没通就把终端里的报错和浏览器对比。看 Response Headers 的 Server 字段。判断请求停在哪一层是网关还是业务进程。读错误体抓 traceId 或请求 ID去日志里找完整链路。这五步的价值在于它是单向收敛的每一步都在缩小范围不存在反复横跳。我之前排查问题最大的时间浪费就来自想到哪查到哪一会儿怀疑跨域一会儿怀疑数据库来回折腾反而更慢。8.2 前端侧能做的防御成本很低前端能在拦截器里做几件很划算的事错误信息里带上完整的请求地址和方法不要只抛一个Failed to fetch。很多人写好几年代码报错信息只有一个通用文案排查全靠猜。区分网络失败和业务失败。error.response存在说明拿到了响应不存在说明网络层或策略层出问题两条路径分开处理日志也分开打。给请求加超时和取消避免组件卸载后回调仍然执行引发的状态错乱。对可重试的错误做有限重试比如 502、503、网络超时但绝对不要对 404 重试那只会在日志里刷出一堆噪音。// 请求拦截器的错误分类示例 function normalizeError(error) { if (error.response) { return { kind: http, status: error.response.status, url: error.config.url }; } if (error.request) { return { kind: network, url: error.config?.url, reason: no-response }; } return { kind: client, message: error.message }; }这段分类逻辑看起来朴素但它在排查时的价值极高——日志里一眼就能看出是服务器回了 404还是压根没连上不用再去猜。8.3 服务端和网关侧的埋点决定了下一次排查能不能五分钟搞定真正能让这类问题不再反复出现的不是前端兜底而是服务端把日志打全。具体来说网关记录原始请求 URI 和转发后的 URI两者一起打前缀问题一目了然。404 响应里带上请求的方法和路径方便和前端日志对齐。给每个请求生成或透传一个请求 ID让前后端日志能串起来。鉴权失败和资源不存在用不同的日志级别别混成一条。我自己的习惯是在网关的 404 处理里加一行 WARN 日志格式固定为方法 原始路径 转发目标 来源 IP。这条日志上线之后前端来问接口 404的时候我基本是直接翻日志几秒钟就能给出结论不用再让前端截图、复现、来回沟通。至于判断到底是路径错了还是资源真的不存在有个很省事的办法把报 404 的那个地址原样粘到浏览器地址栏里敲一下。如果返回的是一段 HTML 或者框架的错误页说明请求没进业务代码如果返回的是规范 JSON那就是业务层给出的答案。这一步不需要任何工具但信息量极大我几乎每次都是从它开始。最后提一个不太起眼但很实用的细节很多偶发 404其实是缓存和部署时间差造成的。新版前端已经上线、旧的构建产物还在 CDN 边缘节点上、或者浏览器还在用旧版本的index.html去请求已经被改名带哈希的 JS 文件。遇到那种刷新一下就好、换个浏览器就好的 404先别怀疑代码去看看 CDN 缓存刷新时间和index.html的缓存策略把 HTML 设成不缓存或者极短缓存这类问题会少掉一大半。
返回列表