Skip to content
bitzorcas
中EN

Concept

HTTP 韧性

说明当前出站 HTTP 审计边界,以及为具体集成设计超时、重试、熔断和幂等策略的方法。

Last updated

BitzOrcas 统一使用 IHttpClientFactory 管理出站连接,并为工厂创建的客户端挂载 ExternalRequestLoggingHandler。当前代码库没有全局 Polly 或 AddStandardResilienceHandler 管道。这是重要边界:现有的审计与超时设置不能被描述成通用重试、熔断能力。

业务端口

Typed/Named HttpClient

集成专属 timeout/retry

ExternalRequestLoggingHandler

外部系统

Result / retry / circuit / fallback

当前已经提供什么

  • AddHttpClient() 与 Typed/Named Client 的统一生命周期管理。
  • ExternalRequestLoggingHandler 记录方法、URL、状态码、耗时、关联标识,以及脱敏后的请求和响应正文。
  • OpenTelemetry 的 HttpClient trace 与 metric 仪表。
  • Gateway 健康探测客户端有独立的 5 秒超时。
  • Webhook 投递由自己的业务状态机处理尝试次数和重试,而不是共享 HTTP 管道。

审计 Handler 会读取并重建内容流,因此接入大文件或流式响应前要评估内存和日志成本。敏感字段虽然会脱敏,仍应避免把无必要的完整正文送入审计链路。

审计 Handler 的实际语义

ConfigureHttpClientDefaults 只覆盖由 IHttpClientFactory 创建的 client;直接 new HttpClient() 或连接器内部自有管线不自动获得该 Handler。注册调用要求位于所有 AddHttpClient 之后,集成 SDK 则可能通过自己的 IRequestLogStore 桥接。

当前 ExternalRequestLoggingHandler 对请求体和响应体都执行 ReadAsStringAsync,用 StringContent 重建,并保留原 content headers。它没有正文大小上限、流式旁路或 content-type 白名单,因此不适用于大文件、二进制和 streaming response。

更关键的是,base.SendAsync 抛出 DNS、连接、TLS 或超时异常时,当前流程不会构造 ExternalRequestRecord;只有收到 HttpResponseMessage 后才记录。因此“所有外部尝试都已审计”是不成立的,网络异常必须依赖 OTel/日志或后续实现补齐。

收到 HTTP response → 读取/脱敏 body → ExternalRequestRecord
发送阶段抛异常 → 当前无 ExternalRequestRecord,异常向上

一个集成需要四个答案

1. 总预算是多少

先定义调用方愿意等待的总时间,再把它分配给单次尝试、退避和下游处理。取消令牌要从入口一路传给 HttpClient;不要用无限超时掩盖下游卡死。

2. 哪些失败可以重试

通常只考虑短暂网络错误、408、429 和部分 5xx。认证失败、参数错误和业务拒绝不应重试。读取 Retry-After 时还要给总预算留余量。

3. 操作是否幂等

安全方法也可能触发有副作用的下游;非安全方法也可能通过幂等键变得可重试。判断依据是对方契约,不是只看 HTTP 动词。

4. 熔断后如何退化

熔断器只阻止继续施压,不会自动提供业务回退。调用方要明确返回错误、使用陈旧数据、排队稍后处理,还是关闭相关能力。

策略顺序与预算计算

一次完整调用的最坏耗时近似为“每次尝试预算 × 尝试次数 + backoff”,还要受入口 request timeout 或 Job shutdown budget 约束。外层总超时必须大于单次尝试但小于调用方 SLA。

总预算 8s
├─ attempt 1: 2s
├─ backoff: 200ms
├─ attempt 2: 2s
├─ backoff: 500ms
└─ 剩余预算留给 attempt 3 / 响应映射

重试通常在 circuit breaker 内还是外,会改变 breaker 统计的是“每次尝试”还是“一次逻辑调用”。策略必须通过测试固定,不应依赖默认装饰顺序。

推荐的接入形态

为每个外部系统注册独立 Typed Client,把 BaseAddress、认证、超时和韧性策略放在同一组合入口。业务 Handler 依赖领域端口,不直接拼 URL。

// ① 先看契约与控制流;校验、取消和类型化错误都要显式保留。
services.AddHttpClient<IPricingClient, PricingClient>(client =>
{
client.BaseAddress = new Uri(options.BaseUrl);
client.Timeout = TimeSpan.FromSeconds(5);
});

如果加入 Polly,应在这个注册点添加该集成专属的重试、熔断和尝试超时,并为幂等与非幂等调用拆分客户端或管道。当前仓库没有可直接复制的全局 Polly 配置。

public static class PricingErrors
{
public static readonly Error Throttled =
Error.Failure("Pricing.Throttled", "Pricing is temporarily throttled.");
}
// Handler 依赖业务端口;适配器负责 HTTP 状态和稳定错误码映射。
public async Task<Result<PriceQuote>> GetQuoteAsync(
QuoteRequest request,
CancellationToken cancellationToken)
{
using var response = await http.PostAsJsonAsync("quotes", request, cancellationToken);
// 429 是稳定可识别结果;网络异常仍交由外层韧性策略处理。
if (response.StatusCode == HttpStatusCode.TooManyRequests)
return Result.Failure<PriceQuote>(PricingErrors.Throttled);
response.EnsureSuccessStatusCode();
return Result.Success((await response.Content.ReadFromJsonAsync<PriceQuote>(cancellationToken))!);
}

对临时网络故障应保留异常语义让韧性层决定重试;最终出边界时再映射为稳定依赖不可用错误。不要把第一次 500 立即伪装成业务 Validation。

非幂等调用

支付、发信、创建远端资源等 POST 只有在对方支持稳定 idempotency key 且重复契约明确时才能自动重试。key 应由本地业务事实持久化,重启后继续复用;每次尝试生成新 UUID 会失去保护。

若对方返回超时但可能已成功,状态是“未知”而不是“失败”。保存 Pending/Unknown,随后按远端业务键查询或对账,不能直接再次创建。

观测与隐私

每次逻辑调用需要 dependency name、operation、结果类别、attempt count、总耗时与 circuit state。URL query、Authorization、Cookie、API key、请求/响应 body 要按集成数据分类脱敏。不要把完整 URL 或客户标识作为 metric label。

大正文审计应改为元数据、hash、大小与受控采样;流式下载不能为审计把全部响应缓冲到内存。

验证清单

  1. DNS 失败、连接拒绝、超时、429、500 和取消均有测试。
  2. 最大尝试次数和总耗时可证明,不会出现重试风暴。
  3. 非幂等请求不会因自动重试重复产生副作用。
  4. 日志与 trace 能关联每次尝试,但不会泄露密钥和个人数据。
  5. 下游持续故障时,有明确的业务退化和告警。

还应验证取消发生在 backoff/发送/读取正文阶段、入口总超时不会被内部重试越过、熔断半开只放行有限探针、503/429 的 Retry-After 被正确限制,以及审计存储失败是否符合既定业务可用性策略。

当前差距

  • 没有全局或标准 resilience handler;
  • 审计 Handler 无 body 上限和 streaming 旁路;
  • 网络异常不会写 ExternalRequestRecord;
  • 重建 StringContent 可能改变具体 content 实现与流式语义;
  • 通用 Handler 记录正文,隐私和内存风险需按集成收缩。

这些差距不阻止模块定义专属 Typed Client,但商业交付必须以每个连接器的策略与故障测试为证据。

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%