API 错误处理与幂等

客户端与服务端的错误处理要解决两件事:哪些失败可以重试(重试是否安全、退避多久),以及重试时如何避免重复副作用(幂等键/幂等接口)。本页给出本项目三套错误信封、可重试矩阵、幂等接口清单与三端重试实现。

三套错误信封​

调用方式信封如何判断失败排查标识
客户端 SDK(REST/WS 封装)RcResult {code,data?,msg?}code === 0 为成功msg + 客户端日志
服务端 Open APIOpenApiResponse {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分类重试建议动作
1008RATE_LIMITED429client是按 Retry-After / 指数退避,降低发送频率
1025QUOTA_EXCEEDED429client是指数退避(1s→30s,带 jitter),最多 3-5 次
2001INTERNAL_ERROR500server是指数退避(1s→30s,带 jitter),最多 3-5 次
2002DEPENDENCY_UNAVAILABLE503server是指数退避(1s→30s,带 jitter),最多 3-5 次
2003STORAGE_ERROR500server是指数退避(1s→30s,带 jitter),最多 3-5 次
2004CALLBACK_DELIVERY_FAILED502server是指数退避(1s→30s,带 jitter),最多 3-5 次
3001SDK_NOT_CONNECTED0sdk是等待 CONNECTED 后重试
3002SDK_TIMEOUT0sdk是指数退避(1s→30s,带 jitter),最多 3-5 次
3003SDK_NETWORK_ERROR0sdk是指数退避(1s→30s,带 jitter),最多 3-5 次

常见的不可重试错误​

错误码名称含义处理
1001INVALID_PARAMETER参数错误修正参数;前端做必填/长度/枚举校验
1002INVALID_TOKENToken 无效或已过期重新签发 Token 后重连,不要循环重试旧 Token
1003FORBIDDEN无权限检查操作者身份与角色(群主/管理员/发送者)
1005GROUP_NOT_FOUND群组不存在群不存在:刷新群列表或提示会话已失效
1006NOT_GROUP_MEMBER未加入群组非群成员:先加入群再操作
1011BLOCKED已被对方拉黑或你已拉黑对方被拉黑:提示关系状态,不要自动重试
1016USER_BANNED账号已被封禁账号封禁:提示申诉流程
1017APP_DISABLED应用已停用应用停用:联系管理员
1018INVALID_SIGNATURE签名错误签名错误:检查 AppSecret/Nonce/Timestamp 与算法
1022RECALL_EXPIRED超过撤回时限撤回超时:超过撤回窗口,改为提示不可撤回
1023MESSAGE_ALREADY_RECALLED消息已撤回已撤回:幂等提示,不重复撤回
1026MESSAGE_NOT_EXPANDABLE消息不可扩展(发送时未开启可扩展,或会话类型不支持)消息不可扩展:发送时未开启可扩展
1027EXPANSION_LIMIT_EXCEEDED消息扩展超出限制(单次 20 对 / 单条 300 对 / 键值长度)扩展数量超限:单条 ≤300 对、单次 ≤20 对
1028REACTION_INVALID回应标识非法(1-16 字符)回应参数非法:reactionId 1-16 字符
1031TAG_LIMIT_EXCEEDED单用户会话标签数超过上限(20)标签数量超限:单用户 ≤20 个标签

幂等:重试不产生重复副作用​

幂等分两层:发送消息用客户端生成的 clientMsgId 做幂等键(同一用户 10 分钟内重复发送返回同一条消息);服务端接口在契约里标注了 idempotent,重复调用等价于一次。当前 230 个 Open API 中有 54 个声明幂等。

模块幂等接口数典型接口
chatroom12chatroom.create、chatroom.kv.set、chatroom.mute-all
group8group.admin.add、group.admin.remove、group.join
user6user.register、user.chat.mute、user.tag.set
ultragroup6ultragroup.notdisturb.set、ultragroup.globalbanned.set、ultragroup.banned.whitelist.add
friend4friend.add、friend.remove、friend.permission.set
sensitiveword4sensitiveword.add、sensitiveword.batch.add、sensitiveword.delete
conversation3conversation.unread.clear、conversation.tag.set、conversation.type.notification.set
blacklist2blacklist.add、blacklist.remove
message2message.reaction.add、message.history.delete
bot2bot.update、bot.integration.update
agent2agent.update、agent.restriction.set
callback1callback.config.set
service1service.activation.set
push1push.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 去重

相关链接​