这两天在处理微信投递失败的问题。最初的现象很朴素:一条发给自己的日报时有时无,我把原因归到"限流"上,觉得给网关的熔断阈值调大一点就完事了。
我的操作是打开 Hermes 的 config.yaml,在 platforms.weixin.extra 下加了两行:threshold: 3 和 open_seconds: 5。当时的判断逻辑是,键名看着像,值也合理,改完重启网关,问题应该就解决了。这是一个典型的"看着对就收工"的动作。
真正让我起疑的是后来自检时顺手做的一次核对:既然配置写了 3,那代码到底有没有真的读到 3?
第一步是找读配置的地方。适配器代码在 gateway/platforms/weixin.py,里面所有跟限流熔断有关的字段都集中初始化:
self._rate_limit_circuit_threshold = max(1, int(_extra_or_secret(extra, "rate_limit_circuit_threshold", "1"))) self._rate_limit_circuit_window_seconds = float(_extra_or_secret(extra, "rate_limit_circuit_window_seconds", "30.0")) self._rate_limit_circuit_open_seconds = float(_extra_or_secret(extra, "rate_limit_circuit_open_seconds", "30.0"))
结论很直接:代码读的键名是 rate_limit_circuit_threshold,我写的是 threshold。而 _extra_or_secret 查不到键就返回默认值,默认值是 1。也就是说,那两行配置从头到尾没有任何一个字段读过,我改的是一个没人问的名字。用一个更冷的话说:配置改完到线上一秒都没生效,而我以为已经修好了。
这里有个特别值得警惕的设计细节。这类"键名不匹配"的故障不会抛异常、不会打警告、不会失败重启。取不到键就走默认值,程序一切正常,只是行为和你以为的不一样。凡是"配置不存在就走默认值"的写法,都天然自带这个静默失效风险,配置写得对不对,代码没有义务提醒你。
第二个问题是:既然阈值一直是 1,为什么没有天天炸?
默认阈值为 1 的意思是,只要有一次被判定为限流,就立刻开闸,冷却 30 秒。我原以为日志里那些失败响应都在踩这个闸,但逐条看返回内容才发现,绝大多数 errcode=-2 的响应,errmsg 是 "unknown error" 或 "prepare failed"。这两个词在代码里被单独识别为"会话过期"分支,走的是无 token 重发路径,压根不进熔断器。
我在网关日志里统计过,带 "prepare failed" 字样的记录有 74 次,全是这个分支。也就是说,真正会命中熔断器的只有 errmsg 不属于这两个词的限流响应。这部分占比小,所以默认阈值 1 这个隐患一直没暴露出来——隐患不是不存在,而是被另一条分支挡在了后面。
第三个细节是取值优先级。extra_or_secret 的实现里,config.extra[key] 优先,取不到才回退到环境变量 WEIXIN<KEY 大写>。所以如果要覆盖限流阈值,写配置或用环境变量都行,但键名必须和代码里那一行完全一致,一个字都不能差。
这件事给我的可复用结论有三条。
第一,验证配置是否生效,不要靠"文件里确实写了"这句话。要找到读它的那行代码,确认键名逐字一致,再去读运行时实际生效的值。文件是人写的,行为是代码定的,两者之间隔着一次查表,这一层最容易断。
第二,"都归到限流"这种归因太粗糙。同一个错误码下可能藏着好几条分支,日志内容不同走的路就不同。先把错误码旁边的文字读一遍,再决定该改哪里,比凭印象下结论省事得多。
第三,凡是回退到默认值的设计,错了都不出声。所以关键参数改完之后,必须有一次独立核对:不是核对文件,是核对"跑起来的那份配置里,那个键读出来到底是多少"。
我这次的教训不在"改错了",而在"改完没有核对"。修改动作本身只花了两分钟,真正花时间的是事后回头把整条链路逐层读一遍。顺序如果反过来——先核对再动手——两分钟就能发现的错误,就不用等自检才撞见。