心跳接口返回 404 却不报错:一次 session_id 轮转陷阱的排查
做签到类自动化时遇到一个很反直觉的现象:同一个接口,第一次调用 200,第二次就 404 session_not_found,而三次调用的代码完全一样。日志里既没有异常也没有报错,只是 accumulated_ms 永远停在两位数,任务永远不达标。
这类问题的共同点是:接口是有状态的,而调用方把它当成了无状态的。本文把排查顺序和最终结论摊开。
一、先确认 404 不是网络层的问题
第一步永远是看响应体而不是状态码。404 在很多网关里会被渲染成 HTML 错误页,而这个接口返回的是结构化 JSON:{"detail":"session_not_found"}。能返回结构化错误,说明请求已经打到了业务层,路由也匹配上了,只是服务端找不到你给的那个 session。
这就排除了三类常见原因:域名解析、反向代理路径前缀错误、以及鉴权失败(鉴权失败通常是 401 或 403,而不是 404)。
二、把「创建」和「心跳」分开看
这类接口通常是两段式:
POST /api/growth/tasks/case-view-daily/sessions -> 返回 {session_id, today:{need_seconds:30, max_gap_ms:15000}}
POST /api/growth/sessions//heartbeat -> 返回 {session_id, accumulated_ms, completed, award_status}
注意心跳的返回体里也有一个 session_id。绝大多数人(包括我)会把它当成「回显」直接忽略掉,继续用第一次拿到的那个。这就是致命点。
三、真正的机制:签名体在轮转
把两次返回的 session_id 各自 base64 解码,就看得出来。它不是一个随机 ID,而是一段带签名载荷的 token:
{"accumulated_ms":3108,"case_id":null,"domain_id":"f3bbe07c...","domain_name":"hid_xxx","exp":1794012...}
每次心跳服务端都会重算累计时长、刷新过期时间,然后用新载荷重新签一个 token 返回。旧 token 对应的记录在服务端已经被替换,所以下一次拿旧 token 去打就是 404。
这不是设计缺陷,而是「无状态服务 + 客户端持有状态」的典型做法:服务端不存 session 表,所有进度都压在这枚 token 里。代价就是调用方必须严格接力。
正确的循环长这样:
let sid = null;
// 只在开始时创建一次
const r = await fetch("/api/growth/tasks/case-view-daily/sessions", {method:"POST", credentials:"include"});
sid = (await r.json()).session_id;
for (let i = 0; i < 12; i++) {
await sleep(3400);
const h = await fetch(`/api/growth/sessions/${encodeURIComponent(sid)}/heartbeat`, {
method: "POST", credentials: "include", headers: {"Content-Type":"application/json"}, body: "{}"
});
const j = await h.json();
if (j.session_id) sid = j.session_id; // <- 关键:接力
if (j.completed) break;
}
四、第二个坑:累计时长按真实时间差算
修好接力之后我发现 accumulated_ms 涨得比预期慢,于是试着在 body 里塞 {"accumulated_ms": 5000},想一次跳 5 秒。结果返回的还是 97。
也就是说服务端完全忽略请求体,用的是服务端自己两次请求之间的时间差。这个设计其实是对的——否则客户端可以随意伪造学习时长。但对调试很不友好:你会以为参数没传对,实际上是参数根本不生效。
配套的还有 max_gap_ms: 15000。相邻两次心跳间隔超过 15 秒,中间那段直接不计入。所以别想着「隔一分钟点一次」凑时长,必须按秒级节奏打。
实测 3.4 秒一跳最稳:既不会因为网络抖动超过 15 秒的空档上限,也不会在一次执行窗口里超时。
五、第三个坑:中途重建会清零
我在中途一度怀疑 session 坏了,于是又调了一次 sessions 接口重新拿一个。结果 accumulated_ms 从 18000 掉回 97。
因为 sessions 是「创建」语义,不是「获取」语义。重新创建等于换了一枚全新的 token,进度天然为 0。判断依据很简单:如果重新创建返回 200 而不是 409,说明之前那个已经废了;如果返回 409 already_completed_in_period,说明今天已经达标,根本不用再跑。
409 也不要理解成错误。它是一条有效信息:别再打了。
六、给这类接口的通用排查顺序
1. 看响应体而不是状态码,确认错误来自业务层还是网关。
2. 把链路上的每个 token 都 base64 解一遍,看它是不是签名体。
3. 逐个比对响应里出现的字段和请求里用到的字段,找「被忽略但其实是新值」的那个。
4. 用固定间隔打三次,记录每次的累计值,判断是「不累加」还是「累加但被重置」。
5. 故意在请求体里传伪造值,如果返回不变,说明服务端按自己的时间算。
6. 中途重新创建一次,看返回 200 还是 409,确定当前 session 是否还有效。
这六步下来,基本能定位到是「接力断了」「间隔超限」还是「重建清零」。三种原因表现都是「进度不涨」,但修法完全不同。
七、小结
这个 bug 最难的地方不是修复,而是它不报错。404 藏在 JSON 里,累计值卡在一个看起来「正常」的小数字上,日志干干净净。如果没有对比响应字段,很容易得出「接口不稳定」这个错误结论,然后加一堆重试——而重试恰好是最糟的做法,因为每重试一次创建就清零一次。
遇到有状态接口,先问一句:这个响应里有没有我没接住的字段。
- 点赞
- 收藏
- 关注作者
评论(0)