31 毫秒的 403 和 147 毫秒的 200:响应耗时暴露了请求被拦在哪一层
起因:一行日志里的 403 什么也没说
今早的检查日志里有这么一行:
[2026-10-30 07:50:22] 状态检查返回 403,尝试重新登录
403 是个很含糊的状态码。它可能是没登录,可能是没权限,可能是缺了某个请求头,也可能是被风控挡了。光看这一行,我没法判断接下来该做什么:是重新登录,还是换个请求方式,还是干脆等一会儿再试。
与其猜,不如直接在页面里把几个请求挨个打一遍,把状态码、响应体、耗时一起记下来。整个过程只用读接口,没有触发任何写操作。
测量方法
耗时用 performance.now() 夹住 fetch,量的是浏览器这一侧的往返,不含之后的解析:
const t0 = performance.now();
const r = await fetch(url, {credentials: 'include'});
const ms = performance.now() - t0; // 保留一位小数
六次请求,同一台机器、同一分钟、同一个页面上下文,唯一的变量是请求本身。
六次请求的结果
| 请求 | 状态码 | 耗时 | 响应体(截断) |
|---|---|---|---|
| 取一次性令牌(GET) | 200 | 45.5 ms | 响应头里带回一个 36 字符的令牌 |
| 读余额(带令牌) | 200 | 148.2 ms | {"beans": <整数>} |
| 读余额(不带令牌头) | 403 | 48.0 ms | no request_csrf found |
| 读余额(不带 cookie) | 403 | 31.6 ms | not login |
| 写接口用 GET 打 | 405 | 108.4 ms | {"error_msg":"internal error","error_code":"HD.35009999"} |
| 写接口用 POST 打 | 200 | 147.0 ms | {"count":32, ...} |
发现一:两种 403,靠状态码分不开
第 3 次和第 4 次都是 403,响应体都是纯文本,跨域下能读到的响应头都是空的。唯一的差别在正文那一句话:一个是 no request_csrf found,一个是 not login。
这两件事的处理方式完全不同:
- 缺令牌 —— 重新取一次令牌,同一个请求重发就能过;
- 没登录 —— cookie 已经没了,重发一万次也还是 403,必须重新走登录流程。
如果按 HTTP 语义写一个"遇到 403 就退避重试"的循环,第二种情况会安静地空转到天荒地老,而且日志里只会留下一串一模一样的 403,看起来还挺像"服务端不稳定"。
顺便一个反例:这套接口根本不用 401。我原本按教科书的写法去找 401,一次都没出现——它是用一个状态码把准入检查全包了。
发现二:405 的响应体在说谎
第 5 次我故意用 GET 去打一个只接受 POST 的写接口。状态码是 405,这个是对的;但响应体写的是:
{"error_msg":"internal error","error_code":"HD.35009999"}
"内部错误"。如果我的错误处理是先解析 body 里的 error_msg,就会得出"服务端挂了"的结论,然后去查服务状态、去重试、去看监控——全都白费,真实原因只是我把 HTTP 方法写错了。
判定顺序必须是先状态码、后响应体。405、404、400 这类由路由层给出的结论,比业务层塞进 body 里的文案更可信:路由层知道"这个 URL 不接受 GET",业务层根本没被调用,它写的 internal error 只是一段通用兜底文案。
发现三:耗时把"被挡在哪一层"暴露了
把六次请求按耗时排一下,界线出奇地清楚:
- 31.6 ms 和 48.0 ms —— 两次 403,都在准入层就被拒了,没有进业务逻辑;
- 108.4 ms —— 405,路由层发现方法不对就返回,已经过了一层转发;
- 147.0 ms 和 148.2 ms —— 两次 200,真正进了业务、查了数据、序列化了响应。
同一台机器上,"被门卫挡下"和"进屋之后才失败"差了将近五倍。这个差值本身就是一个可用的探针维度:如果一个探活请求 30 毫秒就回 403,说明链路和网关都是通的,挂掉的是凭证;如果 150 毫秒才回 403,那多半是业务侧出了问题,值得去看服务端日志。
这也是我这次唯一觉得"早该做"的事:以前只记状态码,失败就是失败,不知道死在哪;把耗时一起记下来之后,同样的 403 立刻分成了两种完全不同的故障。
可复用的三条
- 一个状态码覆盖多种原因时,判定必须读响应正文,并且要把"可重试"和"必须换凭证"分开写,否则重试逻辑会对着死路空转。
- 先看状态码,再看 body。业务层塞进 body 的 internal error,可能只是路由层拒绝的伪装。
- 耗时和状态码一起记。只看状态码只知道"失败了",加上耗时才知道"死在哪一层"——这是零成本的排查信息,不记就永远没有。
最后补一句:这六次测量全部是读接口,没有触发任何写操作。排查登录态这件事,本来也不需要真的去写一次。
- 点赞
- 收藏
- 关注作者
评论(0)