API 错误处理与幂等
客户端与服务端的错误处理要解决两件事:哪些失败可以重试(重试是否安全、退避多久),以及重试时如何避免重复副作用(幂等键/幂等接口)。本页给出本项目三套错误信封、可重试矩阵、幂等接口清单与三端重试实现。
三套错误信封
| 调用方式 | 信封 | 如何判断失败 | 排查标识 |
|---|---|---|---|
| 客户端 SDK(REST/WS 封装) | RcResult {code,data?,msg?} | code === 0 为成功 | msg + 客户端日志 |
| 服务端 Open API | OpenApiResponse {code,msg?,data?,requestId?} | code === 0 为成功 | requestId(响应头/响应体) |
| 长连接帧错误 | Frame {id?,type:'error',data:{code,message}} | type = 'error' | 帧 id 对应请求 |
错误码分段约定:1xxx 客户端/参数/权限类(通常不可重试,1008 限频除外);2xxx 服务端/依赖类(可重试);3xxx SDK 本地错误(不来自服务端)。完整 43 个错误码见 状态码与错误码。
可重试矩阵
43 个错误码中只有 9 个声明为可重试;其余失败重试不会改变结果,应该修正参数、刷新凭据或走业务兜底。
| 错误码 | 名称 | HTTP | 分类 | 重试 | 建议动作 |
|---|---|---|---|---|---|
1008 | RATE_LIMITED | 429 | client | 是 | 按 Retry-After / 指数退避,降低发送频率 |
1025 | QUOTA_EXCEEDED | 429 | client | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
2001 | INTERNAL_ERROR | 500 | server | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
2002 | DEPENDENCY_UNAVAILABLE | 503 | server | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
2003 | STORAGE_ERROR | 500 | server | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
2004 | CALLBACK_DELIVERY_FAILED | 502 | server | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
3001 | SDK_NOT_CONNECTED | 0 | sdk | 是 | 等待 CONNECTED 后重试 |
3002 | SDK_TIMEOUT | 0 | sdk | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
3003 | SDK_NETWORK_ERROR | 0 | sdk | 是 | 指数退避(1s→30s,带 jitter),最多 3-5 次 |
常见的不可重试错误
| 错误码 | 名称 | 含义 | 处理 |
|---|---|---|---|
1001 | INVALID_PARAMETER | 参数错误 | 修正参数;前端做必填/长度/枚举校验 |
1002 | INVALID_TOKEN | Token 无效或已过期 | 重新签发 Token 后重连,不要循环重试旧 Token |
1003 | FORBIDDEN | 无权限 | 检查操作者身份与角色(群主/管理员/发送者) |
1005 | GROUP_NOT_FOUND | 群组不存在 | 群不存在:刷新群列表或提示会话已失效 |
1006 | NOT_GROUP_MEMBER | 未加入群组 | 非群成员:先加入群再操作 |
1011 | BLOCKED | 已被对方拉黑或你已拉黑对方 | 被拉黑:提示关系状态,不要自动重试 |
1016 | USER_BANNED | 账号已被封禁 | 账号封禁:提示申诉流程 |
1017 | APP_DISABLED | 应用已停用 | 应用停用:联系管理员 |
1018 | INVALID_SIGNATURE | 签名错误 | 签名错误:检查 AppSecret/Nonce/Timestamp 与算法 |
1022 | RECALL_EXPIRED | 超过撤回时限 | 撤回超时:超过撤回窗口,改为提示不可撤回 |
1023 | MESSAGE_ALREADY_RECALLED | 消息已撤回 | 已撤回:幂等提示,不重复撤回 |
1026 | MESSAGE_NOT_EXPANDABLE | 消息不可扩展(发送时未开启可扩展,或会话类型不支持) | 消息不可扩展:发送时未开启可扩展 |
1027 | EXPANSION_LIMIT_EXCEEDED | 消息扩展超出限制(单次 20 对 / 单条 300 对 / 键值长度) | 扩展数量超限:单条 ≤300 对、单次 ≤20 对 |
1028 | REACTION_INVALID | 回应标识非法(1-16 字符) | 回应参数非法:reactionId 1-16 字符 |
1031 | TAG_LIMIT_EXCEEDED | 单用户会话标签数超过上限(20) | 标签数量超限:单用户 ≤20 个标签 |
幂等:重试不产生重复副作用
幂等分两层:发送消息用客户端生成的 clientMsgId 做幂等键(同一用户 10 分钟内重复发送返回同一条消息);服务端接口在契约里标注了 idempotent,重复调用等价于一次。当前 230 个 Open API 中有 54 个声明幂等。
| 模块 | 幂等接口数 | 典型接口 |
|---|---|---|
| chatroom | 12 | chatroom.create、chatroom.kv.set、chatroom.mute-all |
| group | 8 | group.admin.add、group.admin.remove、group.join |
| user | 6 | user.register、user.chat.mute、user.tag.set |
| ultragroup | 6 | ultragroup.notdisturb.set、ultragroup.globalbanned.set、ultragroup.banned.whitelist.add |
| friend | 4 | friend.add、friend.remove、friend.permission.set |
| sensitiveword | 4 | sensitiveword.add、sensitiveword.batch.add、sensitiveword.delete |
| conversation | 3 | conversation.unread.clear、conversation.tag.set、conversation.type.notification.set |
| blacklist | 2 | blacklist.add、blacklist.remove |
| message | 2 | message.reaction.add、message.history.delete |
| bot | 2 | bot.update、bot.integration.update |
| agent | 2 | agent.update、agent.restriction.set |
| callback | 1 | callback.config.set |
| service | 1 | service.activation.set |
| push | 1 | push.remark.set |
幂等键的正确用法
消息发送的幂等键是 clientMsgId:同一条业务消息在重试时必须复用同一个值(例如订单号);换了键就会产生两条消息。
服务端接口的幂等是“同参数重复调用不重复生效”,不保证返回内容完全一致(例如返回既有记录而不是新建记录)。
非幂等接口(如创建群、创建聊天室、创建标签)在超时后重试可能产生多条记录:请先用查询接口确认,再决定是否重试。
回调投递是 at-least-once:接收端必须用 eventId 去重(见回调服务)。
重试实现(三端)
const RETRYABLE = new Set([1008, 2001, 2002, 3001, 3002, 3003]);
export async function callWithRetry(fn, { attempts = 3, baseMs = 1000, maxMs = 30000 } = {}) {
let last;
for (let i = 0; i < attempts; i += 1) {
const res = await fn();
if (res.code === 0) return res;
last = res;
if (!RETRYABLE.has(res.code)) throw new Error(`[${res.code}] ${res.msg}`);
const wait = Math.min(baseMs * 2 ** i, maxMs) + Math.random() * 250;
await new Promise((resolve) => setTimeout(resolve, wait));
}
throw new Error(`[${last.code}] ${last.msg}`);
}
// 发送:clientMsgId 复用订单号,重试不会产生两条消息。
const content = { type: "text", text: "你好" };
await callWithRetry(() => client.sendMessage(1, "user2", content, { clientMsgId: orderId }));private static final Set<Integer> RETRYABLE = new HashSet<>(Arrays.asList(1008, 2001, 2002, 3001, 3002, 3003));
void sendWithRetry(IMClient client, String orderId, int attempt) {
client.sendMessage(ConversationType.PRIVATE, "user2", content, orderId,
new SendMessageCallback() {
@Override public void onSuccess(Message message) { /* 成功(幂等键相同则返回同一条) */ }
@Override public void onError(IMError error) {
if (!RETRYABLE.contains(error.getCode()) || attempt >= 3) {
showFailure(error.getCode(), error.getMessage());
return;
}
long wait = Math.min(1000L << attempt, 30_000L);
handler.postDelayed(() -> sendWithRetry(client, orderId, attempt + 1), wait);
}
});
}const retryable = <int>{1008, 2001, 2002, 3001, 3002, 3003};
Future<RcResult<T>> callWithRetry<T>(Future<RcResult<T>> Function() fn, {int attempts = 3}) async {
RcResult<T>? last;
for (var i = 0; i < attempts; i++) {
final res = await fn();
if (res.code == 0) return res;
last = res;
if (!retryable.contains(res.code)) break;
await Future<void>.delayed(Duration(milliseconds: (1000 * (1 << i)).clamp(1000, 30000)));
}
throw Exception("[${last!.code}] ${last.msg}");
}用 requestId 排查服务端失败
- Open API 失败响应带
requestId;把它与时间、接口路径、appkey、业务单号一起记录,服务端日志按它检索即可定位。 - 客户端 SDK 的失败没有 requestId,但与 Open API 使用同一套错误码;可以用 code + msg + 发生时间 + 用户/会话 ID 关联服务端日志。
- 限频(
1008)会带 Retry-After 语义:优先按服务端建议等待,不要固定 1 秒轮询。 - 配额(
1025)不是瞬时错误:先查 API 语义与限制 与配额页,再决定扩容或降级。
反模式清单
| 反模式 | 后果 | 正确做法 |
|---|---|---|
| 对所有错误无脑重试 | 放大故障、触发限频(1008) | 只重试 1xxx 中的 1008 与 2xxx/3xxx 网络类 |
| 固定间隔重试 | 重试风暴 | 指数退避 + jitter,上限 30s |
| 重试时换 clientMsgId | 重复消息 | 同一业务消息复用同一个幂等键 |
| 把 1002 当作网络错误重试 | 循环失败 | 重新签发 Token 后重连 |
| 忽略 total 只取前 100 条 | 搜索/列表结果不完整 | 用 total 提示用户或改用分页接口 |
| 把 at-least-once 当 exactly-once | 重复处理回调/消息 | 按 messageId/eventId 去重 |