拿到一个失败响应,本能反应是去查这个码是什么意思。这个顺序是反的 —— 码值有几十上百个、还会随版本增补,背不完;真正决定处置的只有一件事:现在该做什么。按「该做什么」分,企业微信接口报错只有四类,四类的正确反应互不相同,弄混其中任意两类都会付出可见的代价。这篇按 wecomapi 的统一错误模型讲这四类怎么分、怎么映射到代码、各自该怎么处置。
按「该做什么」分,只有四类
错误分类可以按成因分,也可以按处置分。按成因分是文档的写法,方便查;按处置分是代码的写法,方便执行。业务代码需要的是后者 —— 它不关心这个码属于哪个模块,只关心接下来是停、是等、是修、还是去确认。
- 1确定性失败:参数缺失、结构不合法、对象不存在、能力不在覆盖范围内。特征是同样的请求重发一百次结果完全一样。正确反应是立刻停,回去改代码或改配置。
- 2状态失败:凭证过期、登录态失效、授权范围变更。特征是请求本身没问题,是外部状态变了;做一个特定的修复动作之后,同样的请求可能成功。正确反应是执行修复、重试一次,仍失败就升级。
- 3容量失败:被频控、瞬时并发过高、上游临时不可用。特征是现在不行、过一会儿可能行,而且和你的调用节奏直接相关。正确反应是退避重试,同时降低整体速度。
- 4结果未知:请求超时、连接中断、响应被截断。特征是你压根不知道对方做没做。这不是失败,正确反应是确认,不是重试。
这四类的边界可以用一个问题划清:「同样的请求,什么条件下会有不同结果?」永远不会 —— 第一类;换一个凭证或改一次授权之后会 —— 第二类;等一会儿会 —— 第三类;不知道它到底发生过没有 —— 第四类。绝大多数线上事故,根子都在把某一类当成了另一类。
前两类都不该重试,但错得不一样
确定性失败被重试,浪费的只是几次注定失败的调用,症状是日志里同一个错误刷屏、告警被淹掉。真正的代价是它会掩盖问题:一条参数写错的调用重试三次再进死信,你在监控上看到的是「失败量上升」,而不是「有个字段写错了」。处理方式是让它快速失败并且带上足够的上下文,最好能直接指向调用点。
状态失败被当成普通失败重试,代价大得多。凭证过期时不做刷新、直接重试三次,三次全败,然后这个业务动作被丢进死信队列 —— 而它本来只需要刷新一次凭证就能成功。更糟的变体是「重试时顺便刷新凭证」,多个实例同时撞上过期,就会并发刷新、互相覆盖,把一次可恢复的抖动放大成一段不可用。
状态失败的正确形状是:先执行一个明确的修复动作,修复成功后重试一次,只重试一次。第二次还是同一类错误,说明这不是过期,而是配置或授权层面的问题,重试再多次也不会变 —— 这时候它应该升级成一条需要人看的告警,而不是继续在队列里打转。用 wecomapi 的统一鉴权模型接入时,这个修复动作通常就是刷新一次凭证;同时挂着多条接入路线,就按路线各写一个修复动作,形状不变。
把状态失败和容量失败塞进同一个 catch 统一重试三次,是这四类里最常见的混淆。结果是权限不足的请求白重试,被频控的请求把频控打得更死,而两者在返回里本来是分得清的。
容量失败:退的不该只是这一条请求
容量类的处置人人都知道要退避重试,但多数实现退的只有当前这一条请求:这条 sleep 一下再来,其它并发请求照常往外打。结果是你收到一次频控之后,还在以原速度撞过去,退避的效果被稀释成零。
正确的做法是让容量失败改变全局状态,而不只是当前调用的状态。收到容量类返回时,触发一个共享的降速开关 —— 令牌桶速率下调、或者短暂熔断 —— 让所有在途和后续请求都跟着慢下来,恢复时再逐步放开。这样一次频控换来的是整体节奏调整,而不是一条请求的孤立等待。
还有一条落点上的判断:重试应该发生在队列里,不在请求线程里。在线程里 sleep 会把连接、内存和上游超时预算一起占住,几百条并发退避足以拖垮进程;重新入队并延迟消费则几乎不占资源,还天然带上了重试次数和可观测性。
降速开关的存储粒度要和计量粒度对齐。用 wecomapi 的接入形态时频率约束按账号算,开关就该按账号存;存成一个全局开关的后果是某个账号被限流、其它账号陪着一起慢下来,吞吐白掉一截,而且从监控上完全看不出原因。
退避本身怎么写 —— 指数增长加随机抖动、次数与总时长双上限、超限进死信 —— 在「企业微信 API 频控与重试怎么设计」里有一节专门拆过,这里不重复。只补一条容易漏的:退避期间不要继续往队列里塞新任务,否则恢复的那一瞬间会有一次尖峰,把频控再撞一遍。
第四类最难:超时不是失败
请求超时的时候,你唯一确定的事是没收到响应。对方可能压根没收到请求,可能收到了正在处理,也可能已经处理完了、只是响应在回来的路上丢了。这三种情况在你这边长得一模一样,而它们的正确后续完全不同。
把结果未知当成失败直接重发,是这篇里最贵的一个误判 —— 因为它的代价落在客户身上。同一条消息发两遍、同一个客户被加两次、同一笔通知重复触达,这些都不是技术债,是要向业务解释的事故。而且它有个恶劣的特性:单测测不出来,联调也很难复现,只在生产的网络抖动里出现。
- 写侧调用必须自带幂等键,键由业务意图决定,而不是请求体哈希。有键才谈得上安全重发,没键的写请求超时后只能确认,不能重发。
- 把「已发出、结果未确认」建成一个显式状态,而不是就地重试或直接标失败。这个状态要有超时和出口,不能永久悬着。
- 确认优先于重发:如果有可回查的途径,先查一次结果再决定。查不到再按幂等重发。
- 读侧调用超时可以直接重试,读没有副作用 —— 前提是你确认它真的是读。
监控上也要给这一类单独一个数:未确认状态的积压量。它和失败率是两条曲线,失败率正常而未确认积压在涨,说明网络层出问题了,这个信号比任何错误码都早。
映射写在一处,未知码归到最保守的一类
四类分好之后,落到代码就是一件事:把每一个返回映射到四类中的一类,并且这个映射只存在于一处。分散在各个调用点的 if 判断迟早会不一致,而不一致的分类比没有分类更难查。
映射时有三条纪律。第一,HTTP 状态和业务返回码是两个独立的轴,都要看 —— HTTP 200 里可以裹着一个业务失败,HTTP 5xx 也不一定说明请求没被执行。第二,不要用错误文案做分支判断,文案会随版本调整,判断逻辑必须挂在结构化的返回码上。第三,没见过的码不能默认成可重试,要归到最保守的一类:读请求归确定性失败,写请求归结果未知。默认可重试是一条把小故障放大成大故障的捷径。
// 具体码值、返回结构与可重试性以 wecomapi 线上接口文档为准
const DETERMINISTIC = "deterministic"; // 停
const STATEFUL = "stateful"; // 修一次再试一次
const CAPACITY = "capacity"; // 退避 + 全局降速
const UNKNOWN = "unknown"; // 去确认,不要盲发
function classify(err, req) {
if (err.kind === "timeout" || err.kind === "reset") {
return req.isWrite ? UNKNOWN : CAPACITY; // 读超时可以直接再来
}
if (isAuthProblem(err.code)) return STATEFUL;
if (isRateOrQuota(err.code)) return CAPACITY;
if (isBadRequest(err.code)) return DETERMINISTIC;
return req.isWrite ? UNKNOWN : DETERMINISTIC; // 未知码:保守归类
}
async function dispatch(err, req, ctx) {
switch (classify(err, req)) {
case DETERMINISTIC: return fail(req, err); // 快速失败,带调用点
case STATEFUL: return repairOnce(req, ctx) ?? escalate(req, err);
case CAPACITY: throttle.down(req.accountId); return requeue(req);
case UNKNOWN: return markUnconfirmed(req); // 等确认或按幂等键重发
}
}这段的重点不在写法,在于 dispatch 是唯一出口。业务代码不应该出现第二个地方决定「这个错要不要重试」—— 一旦出现,两处的策略会在半年内漂移,而你只有在事故复盘时才会发现。
告警分级,以及入站方向同样适用
四类的告警级别不该一样。确定性失败通常是发版引入的,量小时进工单、量大或突增时才叫人;状态失败要立刻告警,因为它意味着一整条链路正在停摆;容量失败看斜率不看绝对值,偶发是正常的,持续上升说明调用节奏需要调整;结果未知只要出现就该记录,积压超过阈值就叫人。全部配成同一级别的后果是所有告警一起被忽略。
入站方向也套得上这四类。事件回调处理失败时同样要分:解析不了的报文是确定性失败,不该重试,直接落一份原始报文进人工排查队列;下游依赖不可用是容量失败,退避重放;写库时超时是结果未知,靠事件唯一标识做幂等键重放是安全的。把入站失败一股脑塞进死信队列,等于把三种完全不同的问题堆在一起,谁都不会去清。
本文讲的是分类方法与处置策略,不涉及具体码值。精确的返回结构、错误分类与可重试性以 wecomapi 线上接口文档为准,示意代码里的谓词需要按文档逐条填。
常见问题
- 企业微信api错误太多,要不要把每个码都写进代码?
- 不要。码值会随版本增补,逐个枚举的代码一定会过期。正确做法是维护一张「码 → 四类之一」的映射表,代码只对四类做分支;遇到没见过的码,读请求按确定性失败处理、写请求按结果未知处理,然后补进映射表。这张表的初始内容对着 wecomapi 线上接口文档整理一遍就有了,之后只增不改。这样新增码值最多让一次调用保守失败,不会让处置逻辑错乱。
- 超时之后到底能不能直接重发?
- 读请求可以,写请求不行 —— 除非这次调用带了幂等键。超时只说明没收到响应,对方很可能已经执行成功,无保护地重发就是让客户收到两条消息。做法是给写请求配上由业务意图决定的幂等键,并把「已发出、未确认」建成显式状态,优先回查确认,确认不了再按幂等键重发。
- 怎么区分「权限不足」和「被限流」?
- 看结构化返回码,不要看文案,两者在返回里是分开的。区分的意义在于处置完全相反:权限不足重试一万次也不会变,要去核对配置与授权范围;被限流则应该退避并降低整体速度。这两类的具体码值与判定方式以 wecomapi 线上接口文档为准。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
