错误处理是连接器库设计中最需要明确边界的领域。第三方 API 失败是常态而非异常,如何把厂商各异的错误结构传递给消费者、何时用异常何时用返回值、取消操作如何处理——本节定义全库统一的错误处理约定。
两种错误处理范式
全库存在两种对外暴露失败的范式,由 ADR-0004 确立推荐方向。
| 范式 | 机制 | 适用 | 代表域 |
|---|---|---|---|
| Result(推荐) | 返回 Result<T>,含 IsSuccess + ErrorCode + ErrorMessage | 新域 | Payment、OutboundCall |
| Exception(遗留) | 直接抛 DomainException,消费者 try/catch | 历史遗留 | TeamWork |
为什么 Result 优于 Exception
第三方 API 调用失败不是”异常情况”而是”常见分支”。用 Result<T> 返回结构化的错误码和消息,比裸异常更易被消费者程序化处理——映射到 HTTP 响应、记日志、判断是否重试。内部仍用异常做控制流,但在 Provider 方法边界统一捕获并转换。
域异常体系
没有统一异常根
全库没有单一的全局异常基类供消费者 catch。每个域定义自己的公开异常类型,保证域内自治。
| 域 | 异常类 | 基类 |
|---|---|---|
| Payment | PaymentException | IntegrationException |
| TeamWork | TeamWorkException | IntegrationException |
| OutboundCall | OutboundCallException | IntegrationException |
| FileStorage | FileStorageException | IntegrationException |
| Express | ExpressException | IntegrationException |
| Finance.Invoice | InvoiceException | IntegrationException |
域异常的结构
每个域异常封装供应商原始错误码和消息,继承 IntegrationException 的结构化字段:
public class PaymentException : IntegrationException{ public string ProviderName => Provider ?? string.Empty; // ① 供应商名称 public new string? ErrorCode => base.ErrorCode; // ② 厂商错误码 public new string? ProviderMessage => base.ProviderMessage; // ③ 厂商原始消息
public PaymentException(string providerName, string? errorCode, string? providerMessage) : base( domain: "Payment", message: $"[{providerName}] {errorCode}: {providerMessage}", provider: providerName, errorCode: errorCode, providerMessage: providerMessage) { }}IntegrationException 基类
IntegrationException 是 Core 包提供的可选统一基类,为宿主提供跨域的结构化错误信息。
核心字段
public class IntegrationException : Exception{ public string Domain { get; } // ① 域名(Payment、TeamWork...) public string? Provider { get; } // ② 供应商标识 public string? ErrorCode { get; } // ③ 错误码 public string? ProviderMessage { get; } // ④ 厂商原始消息 public IntegrationFailureKind FailureKind { get; } // ⑤ 稳定失败分类 public int? HttpStatusCode { get; } // ⑥ HTTP 状态码(可用时) public string? RequestId { get; } // ⑦ 追踪 ID public bool IsTransient { get; } // ⑧ 是否可重试 public TimeSpan? RetryAfter { get; } // ⑨ 建议重试等待}IntegrationFailureKind 枚举
FailureKind 是 13 个值的稳定分类枚举,供宿主统一映射状态码、告警和重试策略,无需解析各厂商的字符串错误码:
| 值 | 含义 | 典型场景 |
|---|---|---|
Unknown | 无法分类 | 未预期错误 |
Configuration | 配置缺失或无效 | 密钥为空、URL 格式错 |
Authentication | 认证失败 | Token 过期、密钥错 |
Authorization | 权限不足 | 无操作权限 |
Validation | 参数校验失败 | 金额非法、格式错 |
NotFound | 资源不存在 | 订单号不存在 |
Conflict | 状态冲突 | 重复提交 |
RateLimited | 限流 | 触发 QPS 上限 |
Network | 网络层失败 | DNS、TLS、连接超时 |
Timeout | 调用超时 | 请求超时 |
Provider | 厂商业务/服务端错误 | 厂商返回业务错误 |
Unsupported | 能力不支持 | 当前 Provider 不支持该场景 |
Cancelled | 调用被取消 | CancellationToken 触发 |
IsTransient 与 RetryAfter
IsTransient 标识失败是否可在满足幂等性约束后重试。RetryAfter 是供应商建议的最短重试等待(如限流时返回的 Retry-After 头)。消费者据此实现重试策略:
try{ await provider.DoSomethingAsync(ct);}catch (IntegrationException ex) when (ex.IsTransient){ // ① 瞬时错误——可重试 var delay = ex.RetryAfter ?? TimeSpan.FromSeconds(Math.Pow(2, attempt)); await Task.Delay(delay, ct); // 重试...}catch (IntegrationException ex){ // ② 永久错误——不重试,告警 logger.LogError("永久失败: {Code}", ex.ErrorCode);}Result 范式(推荐)
Payment 域的标准模式
Payment 域是 Result 范式的标准范例。PaymentResult<T> 提供 IsSuccess / Data / ErrorCode / ErrorMessage 和 Success() / Fail() 工厂方法:
public async Task<PaymentResult<PaymentStatusResult>> QueryStatusAsync( string outTradeNo, CancellationToken ct){ try { var json = await _httpClient.GetAsync($"{Path}/{outTradeNo}", ct);
// ① 业务码非成功 → 直接返回 Fail if (GetStr(json, "code") != "success") return PaymentResult<PaymentStatusResult>.Fail("QUERY_FAILED", GetStr(json, "msg"));
return PaymentResult<PaymentStatusResult>.Success(result); } catch (PaymentException ex) { // ② 领域异常 → 提取 ErrorCode 和 ProviderMessage return PaymentResult<PaymentStatusResult>.Fail(ex.ErrorCode ?? "QUERY_FAILED", ex.ProviderMessage ?? ex.Message); } catch (HttpRequestException ex) { // ③ 网络异常 → 统一错误码 return PaymentResult<PaymentStatusResult>.Fail("HTTP_ERROR", ex.Message); }}catch 顺序约定
Provider 方法的 catch 块按从具体到一般的顺序排列:
catch (DomainException):捕获 HttpClient 封装层抛出的领域异常,提取结构化错误码。catch (HttpRequestException):捕获网络层异常,统一映射为HTTP_ERROR。OperationCanceledException不在此处捕获——见下文。
Exception 范式(遗留)
TeamWork 域直接抛 TeamWorkException,消费者必须 try/catch。这是历史遗留范式,ADR-0004 明确不改造。
// TeamWork 域——Provider 直接抛异常public async Task<...> CreateApprovalAsync(...){ var json = await _httpClient.PostAsync(...); if (GetStr(json, "errcode") != "0") throw new TeamWorkException("dingtalk", GetStr(json, "errcode"), GetStr(json, "errmsg")); // ...}
// 消费者——必须 try/catchtry{ await teamWork.CreateApprovalAsync(...);}catch (TeamWorkException ex){ // 处理失败}OperationCanceledException 处理
核心规则:永不吞掉
OperationCanceledException(含 TaskCanceledException)表示调用被消费者主动取消,是控制流语义,不是失败。新代码必须让它透传,绝不吞掉。
// ✓ 正确——用异常过滤器排除取消异常try{ await provider.CallVendorAsync(ct);}catch (Exception ex) when (ex is not OperationCanceledException) // ① 排除取消异常{ // 只处理真正的失败 return Result.Fail(...);}// 取消异常自然透传给消费者// ✗ 禁止——吞掉取消异常会破坏取消语义try{ await provider.CallVendorAsync(ct);}catch (Exception ex) // ① 这会捕获 OperationCanceledException,破坏取消{ return Result.Fail("CANCELLED", ex.Message); // ✗ 错误!}Twilio 的正确示例
OutboundCall.Twilio 用异常过滤器正确处理取消:
catch (Exception ex) when (ex is not OperationCanceledException){ // ① 只处理非取消异常,取消自然向上传播 throw new OutboundCallException(..., innerException: ex);}配置校验:fail-fast
配置校验是 Result 范式的唯一例外——配置错误属于编程错误,必须 fail-fast 抛异常,不返回 Result。
为什么配置校验抛异常
配置错误(密钥为空、URL 格式错)不是运行时可恢复的失败,而是部署错误。fail-fast 能在应用启动时就暴露问题,避免带着错误配置进入生产后逐次调用失败。详见 Options 配置与校验。
// 配置校验——抛异常(通过 IValidateOptions.Fail)internal sealed class DingTalkOptionsValidator : IValidateOptions<DingTalkOptions>{ public ValidateOptionsResult Validate(string? name, DingTalkOptions options) { if (string.IsNullOrWhiteSpace(options.AppKey)) return ValidateOptionsResult.Fail("钉钉 AppKey 不能为空"); // ① 启动期抛异常 // ... }}错误处理决策树
相关约定
- ADR-0004:对外用 Result 而非抛异常——Result 范式的决策记录与理由。
- Options 配置与校验——配置校验为何是 Result 的例外。
- HttpClient 使用约定——HttpClient 层抛异常的边界。
- Core 包——
IntegrationException与IntegrationFailureKind的定义位置。