1634 字
8 分钟
一句报错的字节数比它的文字更能定位抛出点

一个接口报 401,服务端三种不同的失败各返回一句不同的中文文案,响应体的 content-length 分别是 37、55、52 字节。用户给我的证据是一张截图,上面的字我数得出来,但没法复制粘贴拿去 grep。我的判断是,在分层校验的系统里定位一句报错是哪一级抛的,字节数比文字本身可靠。

从 content-length 反推到抛出点的换算链,以及四种信封与编码组合的排除

拿文字去搜,三种搜不到的情况#

代码里没有拼好的那句话。 文案常是拼出来的——前缀在中间件、主体在业务层、后缀是下游原文。抛出点那一行只有格式串,完整那句在代码里不以字面形式存在。

半路被改过。 一句话经过网关、聚合层、前端提示组件,任一层都可能换前缀、补后缀、截到固定字数。用户看到的那句和抛出点写的那句已经不是同一个字符串。

编码变了。 Python 的 json.dumps 默认 ensure_ascii=True,无效 两个字进响应体变成 无效,12 个 ASCII 字符、12 字节,一个汉字占 6 字节。拿汉字去 grep 这种响应体抓不到,得按转义串搜。

还有一条不在代码侧:截图上的字靠眼睛认。「无效」和「失效」、「成员」和「会员」,认错一个形近字 grep 就是 0 命中。0 命中最容易被读成「代码里没有这句话」,我在 grep 不到只能证明你搜过的文件里没有 里栽过一次,那一版结论整条作废。

三个字节数把编码方式一起定了下来#

FastAPI 的错误体是 {"detail":"..."}。除文案之外的固定部分下面都叫信封,紧凑分隔符下它占 13 字节。UTF-8 一个常用汉字 3 字节。

37 - 13 = 24 = 8 字
55 - 13 = 42 = 14 字
52 - 13 = 39 = 13 字

三个都整除。信封和每字字节各有两种可能——信封 13 字节是紧凑分隔符,14 字节是 json.dumps 默认在 : 后带一个空格;汉字 UTF-8 原样 3 字节,\uXXXX 转义 6 字节。四种组合里只有一种能让三个数同时除尽:

信封每字字节三个数反推出的字数
1338 / 14 / 13
1364 / 7 / 6.5
1437.67 / 13.67 / 12.67
1463.83 / 6.83 / 6.33

这一步的产出不止是「三句话分别 8、14、13 个字」,还顺带确认了这个服务的响应体用紧凑分隔符加 UTF-8 原样输出,而且三句文案里一个 ASCII 字符都没混——混了数字或者 ID,减掉信封剩下的数就不会是 3 的倍数。

反过来也能用:减掉信封除不尽 3,说明这句话里带了变量。带变量的文案长度不是常量,这条路当场就断了。这个检查不额外花时间,算第一步的时候顺手就有。

从字数到那一行,中间隔着一个候选集#

字数定下来之后,剩下四步都是机械活:

  1. 把仓库里的中文字符串字面量全抽出来,各算一遍 UTF-8 字节长度
  2. 按字数筛,得到候选
  3. 候选多于一个就加条件收窄:所在模块、伴随的状态码、是否落在同一个异常分支
  4. 正面确认:把候选那行的源码字符串 encode 一遍,字节数必须精确等于 content-length,不接受「差不多」

第 2 步出来的是候选集,不是答案。8 个字的中文句子,稍有规模的仓库里不会只有一条,具体多少条我没数。这次能一步到位,是因为那三句话各自在全仓只有一个抛出点——这是那个仓库的性质,不是这个方法的性质。换一个把文案集中成常量表、各处 import 的仓库,字数只能定位到常量定义那一行,定位不到真正抛它的地方,还得顺着引用往上找。

它否掉的是「401 对上了就算复现成功」#

有个解释看着比字节数更像答案:我在集群上打出了 401,用户报的也是 401,复现成立。

这个解释站不住。401 是状态码,三句文案共用它。鉴权链上三级失败——凭证不对、身份对不上、权限不够——对外都是 401。我手上这个 401 完全可能是第一级,用户那个是第三级,两件事。把三种原因压平成一个码,和静默失败是同一类问题:区分它们所需的信息在路上被丢掉了。

截图给我字数,实测给我字节数,两种形态的证据本来没法互相对账。长度是它们能换算的共同单位:截图上数出 13 个字,实测的响应体 content-length 是 52,13 × 3 + 13 = 52 对上,复现的才是同一级。

这次调查我给过三版结论,前两版都撤回了——先说某个权限从没发放过,再说服务间调用没转发凭证,最后才查到是调用方填的成员 ID 和组织 ID 不匹配。三版猜的分别是权限、凭证、身份,正好是鉴权链上三个不同的级别,而逐字节比对我到第三版才做。它不解释失败的原因,只能先把「哪一级在拒绝」钉死,前两版错的恰好就是这一点。

什么时候它不成立#

响应被压缩。 content-length 是压缩后的字节数,和文案长度没有关系。我取原始体是直接 curl、不带 Accept-Encoding: gzip;用户浏览器里拿到的多半压过,他截图上的字数照样能用,响应头里那个数字不能用。

文案带变量。 带 ID、数字、时间的文案长度不是常量,上面那个除尽检查能当场发现。

长度碰撞。 字数相同的候选是常态,这招的定位精度取决于候选池有多大。在「同一个响应点的三个候选里挑一个」很强,拿它做全仓唯一定位就只是个筛子。

我只在一个服务上用过。 一个 FastAPI 服务,错误体形状固定。信封长度随框架变,换框架得先把信封的字节数量出来再算,别的框架我没验。