Skip to content
bitzorcas
中EN

Guide

Webhooks 投递、幂等、重试与死信

逐步拆解 Webhook 投递服务、幂等行、状态决策、指数退避、原始载荷重放、死信、手动重试和当前自动调度与 attempt history 缺口。

Last updated

WebhookDeliveryService 是事件筛选与 HTTP 发送的编排器。理解它最重要的一点是:当前“一条 delivery row”既是幂等键,又是最新状态快照;它不是投递 attempt 明细表,也没有后台重试调度。

1. 首次投递的精确顺序

DeadLetter PortHttpClientTenant/Client/CIDR/RateWebhookRepositoryDeliveryServiceCAP ConsumerDeadLetter PortHttpClientTenant/Client/CIDR/RateWebhookRepositoryDeliveryServiceCAP Consumeropt[DeadLettered]alt[guard denied][allowed]alt[existing AttemptCount > 0][new]loop[each subscription]DeliverAsync(WebhookEvent)registry.IsRegisteredFindActiveByEvent(tenant,event)payloadHash + timestamp + HMACStartDelivery(event,subscription)new or existing logreturn existing, no HTTPtenant/client/scope/CIDR/rateComplete DeadLetteredEnqueuePOST signed payloadretryPolicy.DecideComplete resultEnqueue

事件类型未注册会让整个 DeliverAsync 失败。某个订阅的 StartDelivery 失败则被外层循环忽略:只有成功的 log 被加入返回列表,最终仍返回 Success。CAP consumer 又忽略 DeliverAsync 的 Result。因此局部仓储失败可能既不触发 CAP 重试,也不进入返回错误。

2. 幂等键与并发窗口

数据库唯一索引是 (EventId, SubscriptionId),Repository 也先查询同组合,存在就返回原日志。新行 ID 使用 UUID v7 的 32 位 N 格式。

这是正确的业务键,但“先查再插”仍有并发竞态:两个实例都查不到后同时 Add,只能依赖唯一索引让其中一个提交失败。Repository 没有捕获唯一冲突并回读原行;实际结果取决于 UnitOfWork 提交边界与 provider 错误映射。

目标幂等写:唯一冲突回读原事实
// 快速路径减少重复事件的写负载,但不能替代数据库唯一约束。
var existing = await deliveries.FindByEventAndSubscriptionAsync(
tenantId, eventId, subscriptionId, cancellationToken);
if (existing is not null)
return existing.ToLog();
try
{
await deliveries.AddAsync(newDelivery, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);
return newDelivery.ToLog();
}
catch (UniqueConstraintException)
{
// 另一个实例已成为赢家;读取它的事实即可把并发冲突转成幂等成功。
// 并发赢家已经写入;返回同一个 delivery,而不是把 CAP 事件标失败。
return (await deliveries.GetRequiredByKeyAsync(
tenantId, eventId, subscriptionId, cancellationToken)).ToLog();
}

当前双 ORM parity 只顺序调用两次 StartDelivery,没有并发压力测试。

3. 投递行保存什么

WebhookDelivery 直接映射 SysWebhookDeliveryLog:

字段当前含义
DeliveryId投递行 ID
SubscriptionId所属订阅
TenantId显式租户隔离
EventId源事件幂等键
EventName开始时的事件类型
TargetUrl开始时的 URL 快照
PayloadJson原始合法 JSON,公共日志不序列化
StatusPending/Succeeded/Retrying/DeadLettered 最新状态
StatusCode最新 HTTP 状态;网络失败为 null
AttemptCount完成次数
Signature最新签名
OccurredAt最新状态变化时间

没有 RequestStartedAt、Duration、error category/message、NextAttemptAt、response headers/body、trace ID、worker、key ID 或每次 attempt 记录。一次手动重试会覆盖 StatusCode、Signature 和 OccurredAt,无法回答“前四次分别失败在哪里”。

4. HTTP 结果分类

DefaultWebhookHttpClient 只返回 (StatusCode, NetworkFailure):

  • 2xx → Succeeded;
  • 网络异常、内部超时、null、408、429、5xx → transient;
  • 其他状态,包括 3xx/4xx → non-transient;
  • transient 且 attempt 小于 MaxAttempts → Retrying;
  • 其他 → DeadLettered。

HttpClient 默认可能自动跟随 3xx,所以 policy 未必看得到原始重定向。它不读取响应体或 Retry-After,也不区分 DNS、连接、TLS、timeout、reset。429 与 503 都走相同指数退避。

5. 退避公式存在,但没有调度

默认 MaxAttempts=5。第 N 次完成后的下次时间:

delaySeconds = min(300, 2^N × 5)
N=1 => 10s
N=2 => 20s
N=3 => 40s
N=4 => 80s

WebhookRetryDecision 返回 Status、ShouldRetry、NextAttemptAt,但 DeliveryService 只使用 Status。后两项不持久化,也没有 BackgroundJob/Quartz/CAP 延迟消息读取。因此一次 503 后行会长期停留 Retrying,除非管理员调用 retry 端点。

platform.webhook.maxRetries=5 与 platform.webhook.retryIntervalSeconds=60 在 SettingDefinitionRegistry 中存在,但 WebhookRetryPolicy 不读取这些设置。实际 max attempts 是构造参数默认值,退避也不是固定 60 秒。

6. CAP 重投与 Webhook 重投相互独立

CAP 可以重投消费消息,但普通 DeliverAsync 遇到已有 AttemptCount>0 的日志会直接返回,不再发送 HTTP。这阻止了重复副作用,却也意味着 CAP 重投不能代替 Webhook retry scheduler。

合理的分层是:

  • CAP 保证平台事件可靠到达 Webhooks inbox;
  • delivery scheduler 负责每个订阅的 HTTP attempt;
  • (event, subscription) 保证创建 delivery 幂等;
  • (delivery, attemptNumber) 保证调度 attempt 幂等;
  • 接收端 EventId inbox 保证外部业务副作用幂等。

7. 手动重试的真实语义

POST /api/webhooks/deliveries/{deliveryId}/retry:

  1. 按当前租户读取 payload-free log;
  2. 读取当前订阅;Deleted 订阅已无法 Find;
  3. Succeeded 返回 WebhookDelivery.AlreadySucceeded;
  4. 单独按租户读取原始 PayloadJson;
  5. 构造同 EventId/EventName/Payload 的新 WebhookEvent;
  6. forceRetry=true,绕过已有 attempt 的短路;
  7. 使用当前订阅 URL、allowlist、Client 状态和 current secret 重新发送;
  8. 在同一 delivery 行累加 AttemptCount。

这不是字节级“原请求重放”:payload 相同,但 timestamp/signature 更新;URL 使用当前 subscription.TargetUrl,而公共 log 的 TargetUrl 仍是第一次开始时的旧值。更新订阅 URL 后重投,会发送到新地址,却在日志中继续展示旧地址。

运维重投前显式核对当前配置
public static class WebhookDeliveryErrors
{
public static readonly Error TargetChanged =
Error.Conflict(
"WebhookDelivery.TargetChanged",
"订阅目标已变更;请选择重放到历史地址还是当前地址。");
}
// Delivery 保存首次目标,Subscription 保存现在将要使用的目标。
var delivery = await deliveryStore.FindAsync(tenantId, deliveryId, cancellationToken);
var subscription = await subscriptions.FindAsync(
tenantId, delivery.SubscriptionId, cancellationToken);
// 当前实现会发往 subscription.TargetUrl,不是 delivery.TargetUrl。
if (!StringComparer.Ordinal.Equals(
delivery.TargetUrl, subscription.TargetUrl.AbsoluteUri))
{
// 地址漂移需要人工选择,不能让“重放”悄悄改变接收方。
return WebhookDeliveryErrors.TargetChanged;
}
return await deliveryService.RetryAsync(tenantId, deliveryId, cancellationToken);

8. 最大次数不限制人工发送

Manual Retry 只拒绝 Succeeded,不拒绝 DeadLettered 或 AttemptCount≥MaxAttempts。服务始终先 HTTP POST,再调用 policy;超过 MaxAttempts 只会让失败结果继续 DeadLettered。管理员可以无限次触发真实外部副作用。

生产方案应区分:

  • automatic retry budget;
  • operator replay budget/approval;
  • break-glass replay;
  • replay to original/current endpoint;
  • dry-run/challenge;
  • payload 是否已超出保留期。

每次操作必须写 Actor、Reason、Ticket/Incident、Old/New attempt、目标地址、结果和 correlation ID。Webhooks 自己的 Retry endpoint 当前没有显式 Activity audit;OpsExtension RetryDeadLetter 有 IActivityAuditSink,但仍需确认生产 sink 可用。

9. 死信不是独立队列

生产组合配置器注册 DeliveryLogWebhookDeadLetterQueue。它只验证传入日志已经 DeadLettered,然后 Task.CompletedTask;真正事实仍是 delivery table 的 Status。它不向消息代理、对象存储或隔离表写入。

OpsExtension 直接查询 WebhookDelivery 中未软删除且 Status=DeadLettered 的行,以 (OccurredAt desc, Id desc) 分页。删除死信只是把 delivery 行软删除;Webhooks 自己的 delivery 查询没有排除 IsDeleted,所以软删除后的行是否仍在普通列表出现取决于底层 EntitySet 全局软删除过滤,代码谓词没有明确表达。

10. 完成写失败被忽略

DeliveryService 调用 CompleteDeliveryAsync 后没有检查 Result。若行未找到或 Update 失败,方法仍构造内存 completed log 并返回 Success;死信端口也可能收到一条数据库并未成功标死信的日志。

HTTP 请求已经发生,完成状态却没有可靠落库,是典型的不可重放窗口。需要 attempt inbox/outbox 或 lease 状态机:先持久化 AttemptStarted,再发送,再以 ExpectedVersion 完成;不确定结果进入 Unknown,而不是假 Succeeded。

11. 推荐的可恢复模型

transientterminal

WebhookDelivery
(event, subscription)

NextAttemptAt + lease

WebhookDeliveryAttempt
number + request fingerprint

HTTP POST

status / duration / error class

retry-after / backoff + jitter

dead-letter + operator decision

退避应加 jitter,尊重有上限的 Retry-After,并按 tenant/client/subscription 公平调度。调度 lease 需防多实例重复领取;Attempt 有唯一 (DeliveryId, AttemptNumber)。Payload 应加密、分类并按保留策略擦除。

12. 测试矩阵

  1. 2xx 全集、3xx redirect on/off、400/408/409/429/500/503;
  2. DNS、connect、TLS、request timeout、caller cancellation;
  3. Retry-After 秒/日期、超大值、错误值和 jitter;
  4. 两实例并发创建同一幂等行;
  5. CAP 重投不重复 HTTP,scheduler 到期准确重投;
  6. 原 URL 与当前 URL 选择、secret 轮换、allowlist 变化;
  7. Complete 写失败、HTTP 成功后进程崩溃和 Unknown 恢复;
  8. manual retry 权限、审计、次数限制、并发点击;
  9. attempt history 稳定分页与 payload 不泄露;
  10. 大租户背压、公平性、停机恢复和死信 Runbook 演练。

13. 审查命令

Terminal window
# 当前没有消费者使用 ShouldRetry / NextAttemptAt。
rg -n "ShouldRetry|NextAttemptAt|WebhookRetryDecision" src -g '*.cs'
# 检查完成写结果是否被传播,修复后不应无条件忽略。
rg -n "CompleteDeliveryAsync|EnqueueAsync" \
src/Platform/Webhooks/BitzOrcas.Platform.Webhooks.Application/WebhookDeliveryService.cs
# 幂等测试应包含并发,而不只是顺序重复。
rg -n "Idempotent|Concurrent|UniqueConstraint|StartDeliveryAsync" \
tests -g '*Webhook*.cs'

返回 Webhooks 总览 · 签名与密钥 · 事件与 GA

100%

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