WebhookDeliveryService 是事件筛选与 HTTP 发送的编排器。理解它最重要的一点是:当前“一条 delivery row”既是幂等键,又是最新状态快照;它不是投递 attempt 明细表,也没有后台重试调度。
1. 首次投递的精确顺序
事件类型未注册会让整个 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,公共日志不序列化 |
| Status | Pending/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 => 10sN=2 => 20sN=3 => 40sN=4 => 80sWebhookRetryDecision 返回 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:
- 按当前租户读取 payload-free log;
- 读取当前订阅;Deleted 订阅已无法 Find;
- Succeeded 返回
WebhookDelivery.AlreadySucceeded; - 单独按租户读取原始 PayloadJson;
- 构造同 EventId/EventName/Payload 的新 WebhookEvent;
forceRetry=true,绕过已有 attempt 的短路;- 使用当前订阅 URL、allowlist、Client 状态和 current secret 重新发送;
- 在同一 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. 推荐的可恢复模型
退避应加 jitter,尊重有上限的 Retry-After,并按 tenant/client/subscription 公平调度。调度 lease 需防多实例重复领取;Attempt 有唯一 (DeliveryId, AttemptNumber)。Payload 应加密、分类并按保留策略擦除。
12. 测试矩阵
- 2xx 全集、3xx redirect on/off、400/408/409/429/500/503;
- DNS、connect、TLS、request timeout、caller cancellation;
- Retry-After 秒/日期、超大值、错误值和 jitter;
- 两实例并发创建同一幂等行;
- CAP 重投不重复 HTTP,scheduler 到期准确重投;
- 原 URL 与当前 URL 选择、secret 轮换、allowlist 变化;
- Complete 写失败、HTTP 成功后进程崩溃和 Unknown 恢复;
- manual retry 权限、审计、次数限制、并发点击;
- attempt history 稳定分页与 payload 不泄露;
- 大租户背压、公平性、停机恢复和死信 Runbook 演练。
13. 审查命令
# 当前没有消费者使用 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'