Serverless 上的 Webhook 处理程序出错的方式就那么几种,固定且可以复现,而几乎每一种的表象都和真正的 bug 对不上。请求体可以证明是正确的,签名验证却不通过。安排在响应之后的任务从来没有执行。处理程序通过了所有本地测试,却只在生产环境、只在高负载时超时。
这些都不是 Vercel 故意刁难。原因在于:以请求为作用域的执行模型,遇上了一个默认服务器长期在线的协议。下面是最常见的五个问题,按你会遇到它们的顺序排列。
TL;DR
- 在任何代码解析请求体之前先
await req.text(),否则每一次签名验证都会失败 - 不要急着用
runtime = "edge"。Fluid Compute 上的 Node.js 才是正确的默认选择 waitUntil能让函数在响应之后继续运行,但它不是持久化的。它不是队列- 冷启动耗掉的是平台的响应时限,而 Shopify 只给你 5 秒
- 处理程序真正的响应时限由最严格的那个平台决定,而不是 Vercel
1. 原始请求体问题
这是每个人最先碰到的问题,而报错信息几乎帮不上忙,因为请求体在日志里看起来一点问题都没有。
每个平台签名的都是它发出的原始字节。Stripe 在原始请求体前加上时间戳前缀再计算 HMAC,Shopify 对原始请求体计算 HMAC,GitHub 也对原始请求体计算 HMAC。从网络到你的验证调用之间,只要有任何一层把 JSON 解析后再重新序列化,键的顺序或空白就会差出一个字节,HMAC 也就对不上了。数据完全相同,签名却不同。
在 App Router 中,先读取文本,再从已经拿到的字符串解析:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const raw = await req.text(); // exact bytes, before any parsing
const sig = req.headers.get("stripe-signature");
if (!sig) return new Response("missing signature", { status: 400 });
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
raw, sig, process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch {
return new Response("invalid signature", { status: 400 }); // no details to the caller
}
await queue.publish(event);
return new Response(null, { status: 200 });
}
有两点要分清楚。
绝不要先调用 await req.json()。那样你就再也拿不回原始字节:流已经被读完,而你重新序列化出来的内容,并不是被签名的那一份。
Pages Router 需要显式关闭 body parser。如果你还在用 pages/api,设置 export const config = { api: { bodyParser: false } },然后自己读取原始流。大多数“本地能用、部署后不行”的问题,查到最后都是少了这一行的某种写法。
各平台的签名格式(Stripe 的 t=/v1= 方案、GitHub 的 sha256= 前缀、Shopify 的 base64),见验证 Stripe、GitHub 和 Shopify 的 Webhook 签名。平台 SDK 提供了验证函数的,就用它,不要自己手写:它替你做了常量时间比较和时间戳容差,这两样都很容易在细节上写错。
2. 不要把 Webhook 处理程序放在 Edge 运行时上
很多人一直有这样一种直觉:Webhook 接收端必须快,所以应该用 runtime = "edge"。这是过时的建议。
Vercel 的默认配置是 Fluid Compute 上的 Node.js,它在并发请求之间复用实例,当初让 Edge 显得诱人的冷启动差距,已经基本被抹平。与此同时,Edge 运行时会让你失去 Webhook 处理程序实实在在需要的东西:完整的 node:crypto、多数平台 SDK 默认依赖的 Node API,以及更长的执行时间窗口。人们常提的另一个理由是流式响应,而它在 Node.js 上同样能正常工作,而且已经支持了一段时间。
具体会这样出错:调用 crypto.createHmac 的平台 SDK 在 Edge 上直接抛错,于是你用 WebCrypto 手写验证,而时序安全的比较就在这个手写版本里被悄悄丢掉了。你用一项实实在在的安全保障,换来了一个基本已经消失的冷启动差距。
保持默认配置。
如果你眼前已经有一个本地正常、生产环境出错的处理程序,在生产环境排查失败的 Webhook 里有一棵决策树,帮你缩小范围,找出是哪一层吞掉了请求。
3. waitUntil 不是队列
在 Vercel 上,要“先快速返回,再处理”,最自然的做法是 waitUntil:
import { waitUntil } from "@vercel/functions";
export async function POST(req: Request) {
const event = await verify(req);
waitUntil(syncToSalesforce(event)); // continues after the response
return new Response(null, { status: 200 });
}
它确实有用,但它确实不等于持久化。这个区别比听上去更重要:
- 实例如果在执行途中被回收或崩溃,任务就没了。没有重试,没有死信,没有记录
- 你已经向平台返回了 200。它永远不会再发。这个事件从此在哪里都不存在
waitUntil里的失败不会让请求失败,所以除非你显式捕获并上报,否则根本看不见
这是最糟的一类故障:罕见、无声,事后也无法还原,因为事件唯一的副本只在内存里。
waitUntil 适合那些丢了也无妨、发出就不管的任务:统计上报、缓存预热、一条 Slack 通知。凡是丢了客户会察觉的事情,都不该交给它。
让你远离麻烦的规则是:先持久化,再应答。把事件写进 Postgres、Redis 或真正的队列,然后返回 200,再去处理。这时 200 才是一句真话,因为事件确实在你手里,而处理过程可以失败,也可以按需要重试任意多次。
export async function POST(req: Request) {
const event = await verify(req);
await db.insert(events).values({ id: event.id, payload: event }); // durable
waitUntil(processNow(event.id)); // fast path; a cron sweeps anything left behind
return new Response(null, { status: 200 });
}
快速路径处理正常情况。定时扫描处理中途挂掉的实例。两者都不会丢事件。
4. 冷启动花的是平台给你的时间
Vercel 默认的执行时长上限是 300 秒,听起来超时根本不成问题。事实并非如此,因为给你计时的不是 Vercel。给你计时的是调用你的那一方,而它们的时限要紧得多:
| 平台 | 响应时限 |
|---|---|
| Slack、Discord | 3 秒 |
| Shopify | 5 秒 |
| GitHub | 10 秒 |
| Twilio | 15 秒 |
| Stripe | 未公布 |
处理程序真正的超时,取决于你接收的平台里最严格的那一个,所以值得弄清你面对的每一个时限,而不只是你记得的那一个。我们的 Webhook 平台参考列出了它们。面对 Shopify 的五秒,一次 2 秒的冷启动在你的第一行代码执行之前,就已经用掉了 40% 的时限。超时检测工具会向你的路由发两次请求并分别计时,冷启动的那次和热的那次并排显示;如果慢在模型调用上,你的 Webhook 处理程序调用了大模型(LLM)讲了 after() 的用法和它管不到的地方。
真正有用的做法:
- 让路由的导入图保持精简。一个只负责验证和入队的 Webhook 路由,不应该间接导入你的 ORM、admin SDK 和邮件客户端。它们每一个都要在冷启动时解析
- 把重量级的依赖放进真正需要它们的分支里懒加载,而不是在模块顶层导入
- 给 Webhook 路由设一个较低的
maxDuration。export const maxDuration = 10不会让你变快,但它会把失控的处理程序变成一次快速失败,平台会重试;否则就是挂起 300 秒,白白消耗算力,问题却一点没有解决
5. 凌晨 3 点,并发不是你的朋友
Serverless 会横向扩展。限时抢购期间你要的正是这个,而它恰恰也打破了大多数幂等代码赖以成立的假设。
一百个并发实例意味着一百个并发的处理程序,每个都在打开数据库连接。Postgres 有连接数上限,而且比你以为的要低。平台上的连接池就是为此准备的;跳过它,一次流量高峰就会让每个请求都报 too many connections,包括真实用户的请求。
更隐蔽的一个问题:同一个事件的两次重试,可能在同一毫秒落到两个实例上。先读后写的幂等逻辑在这里存在竞态,顺序执行的测试永远发现不了。这件事必须交给唯一约束来做,而不是一个 if:
// Racy: both instances read "not processed", both proceed
if (await db.hasProcessed(event.id)) return ok();
await doWork(event);
// Safe: the database arbitrates
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING`,
[event.id],
);
if (rowCount === 0) return ok();
await doWork(event);
更多内容(包括乱序投递)见同一个 Webhook 收到了两次。
AnyHook 能做什么
上面除了原始请求体那一条,其余说到底都是同一个问题:一个以请求为作用域的运行时,被要求做出它自己兑现不了的持久化承诺。
AnyHook 把这个承诺从函数里移了出去。平台把请求 POST 到 in.anyhook.net/you/app,然后:
- 发送方约 1 秒就能收到 200,由边缘节点上一个只负责持久化和应答的 Worker 返回。Shopify 的五秒不再是你的约束
- 事件在我们应答之前就已持久化保存,并做了静态加密,所以“实例挂了”只意味着一次重试,而不是一次丢失
- 投递到你的 Vercel 函数失败后,会在 5 分钟、30 分钟、3 小时、18 小时后重试,之后每天一次,视套餐共 3 到 10 次尝试;仍然失败的事件留在日志里,可以重放
- 一个
AnyHook-Signature请求头取代各平台各自的验证方案,每次投递尝试都用新的时间戳重新签名,所以重试不会像转发过来的平台签名那样,因超出时间容差而验证失败 - 可以按时间范围重放,应对一次部署窗口吞掉一小时事件的情况
你的处理程序仍然必须幂等。这一点什么都替代不了。但它不再需要同时做到持久、快速、始终保持热启动。
不注册任何东西也能先看看效果:一条命令就能拿到一个可用的接收 URL,无需账号:
curl -X POST https://anyhook.net/api/v1/quickstart
响应里有一个 inbound_url,填到平台那边;一个 api_key,用来通过 API 读取事件日志;还有一个 claim_url。这个端点是临时的:7 天后它和其中的事件都会被删除,除非你打开认领 URL 并登录,认领后 URL 保持不变。
总结
先读原始请求体,留在 Node.js 运行时上,把 waitUntil 当作尽力而为的机制,而不是队列。如果只从本文带走一个结构上的原则,那就是:对还没有持久写下来的事件,永远不要返回 200。清单上的其他问题都是性能问题。这一个是数据丢失问题,也是唯一一个事后无法调试的问题。
Workers 版本的清单差别很大,所以我们单独写了一篇:在 Cloudflare Workers 上接收 Webhook。