错误处理指南
本文说明 Mud.Feishu SDK 的错误通道设计,重点覆盖文件下载类接口(Task<byte[]?>)这一 与其余接口语义不同的特例。
1. 统一响应模型(绝大多数接口)
除文件下载外,所有接口返回 FeishuApiResult<T>?(或 FeishuApiListResult<T>? / FeishuApiPageListResult<T>? / FeishuNullDataApiResult?):
var result = await _messageApi.SendMessageAsync(request, "open_id");
if (result is { Code: 0 })
{
// 成功
}
else
{
// 业务错误:result.Code / result.Msg
}注意:HTTP 200 且 Code != 0 是飞书最常见的业务错误形态,此时不会抛异常, 必须显式检查 Code。
2. 传输层错误
服务端返回非 2xx 状态码时,HTTP 执行器统一抛出 Mud.HttpUtils.ApiException:
| 属性 | 说明 |
|---|---|
StatusCode | HTTP 状态码(401 / 403 / 404 / 429 / 5xx 等) |
Content | 响应体内容(已按 MaxExceptionContentLength 截断;敏感内容经 IExceptionRedactor 擦除) |
RequestUri | 请求地址(已脱敏) |
RequestContent | 捕获的请求体(仅在启用 CaptureRequestContent 时填充) |
try
{
var result = await _userApi.GetUserAsync("ou_xxx");
}
catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
// 触发飞书限流:按 Retry-After 退避重试
}401 由 SDK 自动恢复:应用上下文中的 TokenRecoveryEnhancedClient 会刷新令牌并重试一次, 业务代码通常无需处理(仅在自行调用认证接口获取令牌的场景下需要留意)。
3. 文件下载类接口(Task<byte[]?>)
以下 9 个方法直接返回二进制内容,不经过 FeishuApiResult<T>,因此其错误语义需要单独说明:
| 模块 | 方法 |
|---|---|
IFeishuV1Message_Tenant | GetMessageFile、DownFileAsync、DownImageAsync |
IFeishuV1DriveFiles | DownloadFileAsync、DownloadExportFileAsync |
IFeishuV1DriveMedia | DownloadFileAsync |
IFeishuV1Board | DownloadWhiteboardImageAsync |
IFeishuV1VideoConferencingExports | DownloadExportAsync |
IFeishuV1AttendanceUserSettings_Tenant | DownloadFileAsync |
IFeishuV1HelpDeskTicket_Tenant | GetTicketImageAsync |
行为契约(已由 Tests/Mud.Feishu.Tests/Http/DownloadErrorSemanticsTests.cs 锁定)
- 服务端返回 非 2xx → 抛
ApiException(Content为错误响应体)。 调用方无需区分「失败」与「成功但内容为空」,前者是异常。 - 服务端返回 2xx → 原样返回响应体字节(可空签名,实际不会为
null;空响应体对应空数组)。 - 残余风险:HTTP 200 + JSON 错误体。飞书部分业务错误以
HTTP 200配合{"code":99991672,"msg":"..."}返回,此时本方法会把错误 JSON 的字节当作文件内容返回。 执行器不做内容嗅探(无法在不破坏二进制语义的前提下区分),因此需要调用方自检:
var bytes = await _driveFilesApi.DownloadFileAsync(fileToken);
if (bytes is null || bytes.Length == 0)
{
throw new InvalidOperationException("下载内容为空");
}
// 低价自检:错误响应体总是 JSON,且以 '{' 开头
if (bytes.Length > 1 && bytes[0] == (byte)'{')
{
var errorJson = System.Text.Encoding.UTF8.GetString(bytes);
throw new InvalidOperationException($"下载失败,服务端返回错误体:{errorJson}");
}
await File.WriteAllBytesAsync(localPath, bytes);若需要更严格的判定,可改用
IFeishuV1DriveFiles等接口上形如Task<FeishuApiResult<...>?>的元数据接口先校验文件是否存在 / 是否有权限, 再下载内容。
4. 异常类型速查
| 异常 | 触发场景 |
|---|---|
ApiException | 非 2xx 状态码;JSON/XML 反序列化失败 |
ApiRequestException | 传输层失败(DNS / TLS / 连接被拒),或成功响应体超出 MaxSuccessResponseBytes |
TaskCanceledException | 请求超时(HttpClient.Timeout 或弹性策略超时)或调用方取消 |
InvalidOperationException | 配置缺失(如使用 [Body(EnableEncrypt=true)] 但未注册 IEncryptionProvider) |
5. 相关配置
| 配置项 | 作用 |
|---|---|
FeishuAppConfig.TimeoutSeconds | 命名客户端超时(秒),支持配置热更新 |
FeishuAppConfig.HttpRetry.MaxAttempts / HttpRetry.DelayMs | 弹性重试(指数退避) |
FeishuAppConfig.CircuitBreaker.* | 熔断策略 |
EnhancedHttpClientOptions.MaxSuccessResponseBytes | 成功响应体上限(防 OOM,0 = 不限制) |
EnhancedHttpClientOptions.MaxExceptionContentLength | 异常内容截断长度 |
EnhancedHttpClientOptions.CaptureRequestContent | 是否捕获请求体用于诊断(含敏感数据,慎用) |
6. 事件处理投递语义(at-least-once)
WebSocket / Webhook 事件链路不追求严格一次,契约为 at-least-once + 尽力幂等。 业务失败或服务端超时后,飞书可能重发;处理器必须幂等,或以业务唯一键兜底 (IdempotentFeishuEventHandler 默认业务键为 "{HandlerType}:{EventId}",禁止返回裸 EventId)。
去重所有权分层
| 层级 | 负责组件 | 标识 | 失败时 |
|---|---|---|---|
| 传输层 | WS BinaryMessageProcessor + MessageSequenceValidator + SeqID 去重 | SeqID | 统一回滚窗口/SeqID 标记,ACK 500 触发重发 |
| 事件层 | FeishuEventMessageHandler / FeishuWebhookService | EventId | 回滚 processing 态;Mark 失败不回滚(WHF-07) |
两通道终态对照
| 终态 | WebSocket | Webhook |
|---|---|---|
| 业务成功 | Mark completed → ACK 200 | Mark completed → 200 |
| 业务失败 | 回滚 + ACK 500(重发) | 回滚 + 失败存储 + 500(或按配置返回) |
| Mark 失败 | 保留 processing,ACK 200(WHF-07) | 保留 processing,按成功返回 |
| 被拦截 | 回滚去重 + 重抛 → ACK 500(retry-until-accept) | 不进去重 + (false,"Event intercepted") → 500 |
| 空 EventId | 默认拒绝(RejectEmptyEventIds=true) | 默认 400(RejectEmptyIdentifiers=true) |
| 未注册事件 | 默认回退默认处理器;IgnoreUnknownEventTypes=true 时忽略 | 默认忽略(IgnoreUnknownEventTypes=true) |
| 去重体系致命故障 | (WS 无对应 WHF-02 中间件转换) | FeishuDeduplicationFatalException → 不回滚/不写失败存储 → 503 |
| 外部取消 / 超时 | OCE 传播(ACK 500/停机) | 超时就地收尾;外部取消回滚后 rethrow |
后置拦截器 AfterHandleAsync(exception):业务成功为 null; 拦截/取消等特殊终态可能收到 EventHandlingOutcomeException(OutcomeKind 为 intercepted/canceled/dedup_fatal)。
失败重试与扇出
- 启用
Retry.EnableRetry时,业务失败会写入IFailedEventStore(ADR-2);FailedEventInfo可含SerializedHeader/StoreKey。 - 多处理器扇出任一失败将整体回滚去重并依赖重发,已成功的处理器会重复执行(见
IFeishuEventHandlerFactory.HandleEventParallelAsyncXML)。