当你的服务器宕机,而 Stripe 正好发出一个 payment_intent.succeeded,这个事件会怎样?Stripe 会一直重试吗?重试多久?你又该怎么对账,找回漏掉的事件?
这是我从接入 Stripe 的开发者那里听到最多的问题。它之所以重要,是因为一个你没能处理的 Webhook,就是一笔你的产品无法据此行动的收入。客户刚付了款。你的履约任务却从没触发。客户的控制台也从没更新。
TL;DR
- Stripe 按指数退避重试 3 天,然后永久停止
- 尽快返回 200,异步处理工作,处理之前记录每个事件,做到幂等
- 如果你没时间把这些都做出来,一个 Webhook 转发服务只需改一个 URL,就能给你重试、日志和重放
Stripe 怎么重试
Stripe 期望你的 Webhook 端点尽快返回 2xx 状态码。它没有公布确切的数字,文档里只说要“在执行任何可能导致超时的复杂逻辑之前”返回 2xx。社区普遍按 20 秒来算。超时的投递算作失败,5xx 响应和网络故障也一样。
失败后,Stripe 会按指数退避重试这个事件,最长 3 天。重试间隔越来越长,最后一次的时间窗口大约在原始事件之后 72 小时。
3 天后,Stripe 把事件标记为失败,停止重试。它不会叫醒你。也不会发一封兜底邮件。这个事件就这样从你的投递队列里消失了,不过你仍然能在 Stripe 控制台的 Developers → Events(开发者 → 事件)里看到它。
Stripe 的重试曲线也算温和的。记录商户(Merchant of Record)类平台的重试可能猛烈得多:Paddle 在大约两天内最多尝试 60 次,一个跟得上 Stripe 节奏的处理程序,仍可能被 Paddle 的重试淹没。
如果失败持续得更久,Stripe 最终会彻底停用这个端点,并发邮件告诉你停用日期。从那种状态恢复是另一项工作,事件在 30 天内仍可以通过 Events API 拉取:回填步骤见 Stripe 停用了你的 Webhook 端点。
会咬人的三种故障模式
你的端点太慢。超过 Stripe 那个未公布的超时时限的响应,会被算作失败。如果你的 Webhook 处理程序同步写入一个慢数据库、同步到 Salesforce,还要给客户发邮件,那每个事件都在超时的边缘试探。
你的部署窗口。服务器在部署期间重启,漏掉了大约 10 个事件,然后恢复运行。Stripe 会重试这些事件,但只重试 3 天。
一个 bug 一直返回 500。你发布的某次部署在处理某种特定事件类型时抛异常。Stripe 会重试,但一直撞上同一个错误。3 天后 Stripe 放弃,你就有了一个无声的数据缺口。
这三种情况下,Stripe 的做法都是对的。问题出在管道你这一端。
生产级集成会做的四件事
1. 尽快返回 200,异步处理工作
不要在 Webhook 处理程序里做繁重的工作。接收事件,把它写进队列或持久化日志,返回 200,然后异步处理。这样能让你离超时远远的,也把投递和业务逻辑解耦开。
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
await queue.publish(event); // durable write
return new Response(null, { status: 200 });
}
2. 处理之前记录每个事件
在动业务逻辑之前,先把原始请求体和请求头持久化。如果你的逻辑抛了异常,你可以从日志重放。Stripe 3 天后停止重试,事件保留 30 天,过了这个期限,你的日志就是唯一能让你恢复的东西。
3. 在你这一侧做到幂等
Stripe 重试时会发送同一个 event.id,所以你的处理程序必须幂等:如果你已经处理过 evt_1H...,就不要再处理一次。一个简单的模式:
INSERT INTO processed_events (stripe_event_id, processed_at)
VALUES ($1, NOW())
ON CONFLICT (stripe_event_id) DO NOTHING;
如果插入返回的受影响行数为 0,说明你之前见过这个事件。跳过。
一旦重试开始并发到达,或者乱序到达,幂等性就比看起来复杂得多。同一个 Webhook 收到了两次讲了事务边界、针对无法回滚的副作用的“先占位、再提交”模式,以及为什么解决乱序投递靠的是版本保护,而不是去重。
4. 失败告警
如果连续 5 个事件失败,你希望几分钟内就知道,而不是等客户给支持团队发邮件。跟踪连续失败的次数,触发告警叫醒自己。
AnyHook 能做什么
AnyHook 位于你的服务器前面,在传输层解决这个问题。你把 Stripe 指向 in.anyhook.net/you/stripe,而不是你的源站,然后我们会:
- 约 1 秒向 Stripe 返回 200,所以响应时限永远不会成为问题
- 记录每个事件的完整请求头和请求体,并做静态加密
- 按 5 分钟、30 分钟、3 小时、18 小时,之后每天一次的计划重试投递到你的服务器:Free 套餐 3 次尝试,Pro 5 次,Scale 10 次。Stripe 自己的重试计划最长三天,所以在 Free 和 Pro 套餐上,真正的长期保障是已保存的日志和重放,而不是重试次数
- 提供一键重放。如果你的服务器宕机了一小时,在 App 页面上按一下,就能重放这一小时里失败的事件(每次最多 100 个),而且重放失败的事件不计入你的月度配额,不过有每日上限,并且会消耗你的计算额度
- 对连续失败发出告警。连续失败 1 次和 5 次时,我们会给你发邮件。连续失败 20 次时,我们暂停向该转发目标投递(第一次暂停 10 分钟,再次触发时更久),并再次发邮件;暂停期间到达的事件会被记录下来供重放,而不是投递出去
不需要 SDK,只换一个 URL。首次投递仍然能通过你现有的 Stripe 签名检查。重试和重放带的是 Stripe 原始的时间戳,Stripe SDK 会在五分钟后拒绝它,所以要接受这些投递,就检查 AnyHook-Signature 请求头。
想在注册任何东西之前先看看效果,一条命令就能返回一个可用的接收 URL,不需要账号:
curl -X POST https://anyhook.net/api/v1/quickstart
响应里有一个 inbound_url,用来填到平台那里;一个 api_key,用来通过 API 读取事件日志;还有一个 claim_url。这个端点是临时的:除非你打开认领 URL 并登录,否则 7 天后它和它的事件都会被删除;认领之后 URL 保持不变。
总结
如果你现在正在丢 Stripe 事件,解决办法通常不是加大重试。而是先持久化、再处理,这样你永远有东西可以重放。