Webhook 发送方只等几秒,不会等几分钟:Slack 给你的处理程序 3 秒应答,Shopify 给 5 秒,GitHub 给 10 秒,而调用大模型(LLM)的处理程序通常需要更久;于是发送方记下一次失败,重试或者放弃;失败得足够频繁,Slack 就会停用你的事件订阅,Shopify 则可能删除这个 Webhook。解决办法是请求验证通过后立即返回 200,等响应发出后再调用模型:要么放在你自己运行的后台任务里,要么放在一个替你应答发送方的转发服务后面。
下文是这两句话的详细版:每个发送方实际等多久,错过时限后它会怎么做,四种先应答、后处理的简短写法(以及各自的问题),还有改由转发服务保存事件时会有什么不同。
TL;DR
- 发送方的计时覆盖整个处理程序:签名验证、数据库读取,还有模型调用
- 错过时限,发送方就会重试。已经把活干完的处理程序会再干一遍:Slack 里两条回复,一个订单被打两次标签,两封邮件
- 错过的次数够多,Slack 会停用你的事件订阅,Shopify 可能会删除这个 Webhook。GitHub 从不重试
- 先应答,后处理:Next.js 用
after(),Cloudflare 用ctx.waitUntil()(最多 30 秒),Express 或 FastAPI 用后台任务 - 后台任务一旦挂掉,这几种写法都留不住事件。持久的版本需要存储、工作进程和重试计划,也就是一个队列
- 转发服务就是那个不用你自己写的队列:AnyHook 约 1 秒应答发送方,在 Free 套餐下给你的处理程序 60 秒,以套餐的计算额度为限,并且会重试
每个发送方等多久
这些数字来自各平台自己的文档,连同出处汇总在我们的 Webhook 平台参考里。
| 发送方 | 等你返回 2xx 的时间 | 投递失败之后 |
|---|---|---|
| Slack | 3 秒 | 重试 3 次:立即、1 分钟后、5 分钟后。60 分钟内超过 95% 的尝试失败,事件订阅就会被停用 |
| LINE | 2 秒(更晚的应答会被记为 request_timeout) | 重发默认关闭。如果你的服务器持续失败,LINE 可能暂停发送 Webhook |
| Shopify | 5 秒(建立连接限 1 秒) | 4 小时内重试 8 次。通过 Admin API 创建的订阅连续失败 8 次后会被删除 |
| HubSpot | 5 秒,由整批(最多 100 条通知)共用 | 24 小时内最多尝试 10 次 |
| Paddle | 5 秒 | 正式环境:约 2 天内最多尝试 60 次 |
| GitHub | 10 秒 | 无。3 天内可以手动重新传递 |
| Twilio | 默认 15 秒,可配置 | 默认重试 1 次,可配置为 0 到 5 次 |
| Stripe | 未公开。它的文档要求在执行任何耗时逻辑之前返回 2xx | 真实模式下按指数退避重试,最长 3 天 |
你真正的时限,是你接收的发送方里最严格的那一个。Slack 上的机器人只有 3 秒,而一次冷启动可能在你的第一行代码运行之前就耗掉大半。
为什么调用大模型会出问题
在 2026 年写的处理程序里,模型调用几乎总是最慢的一步。一次简短的补全要几秒;带工具调用、长回答,或者 SDK 内部重试过的,要几十秒。再加上一次冷启动,第一个 token 还没到,发送方就已经挂断了。
接下来会发生什么取决于发送方,没有一种是好事:
- Slack 会重试。第一次运行还在等模型,你的处理程序又收到了同一个事件。两次运行都会跑完,也都会发消息。用户看到机器人回答了两遍
- Shopify 先重试,再删除。四个小时里连续失败八次,通过 Admin API 创建的订阅就没了。订单还在不断进来,你的应用却再也收不到通知
- GitHub 什么也不做。这次投递被标记为失败,事情到此为止,除非有人在三天内手动重新传递
- LINE 的重发是关闭的,除非你打开过,所以一次超时就是一条你的机器人永远没有回复的消息
处理程序并没有出错。它只是比一个它看不见的时钟慢了。
解决办法:先应答,后处理
验证请求,返回 200,然后再调用模型。每个平台都有办法在响应之后继续运行。每种办法也都有一个问题,写在代码下面。
Next.js (App Router)
// app/api/slack/events/route.ts
import { after } from "next/server";
export async function POST(req: Request) {
const raw = await req.text();
if (!verifySlackSignature(raw, req.headers)) {
return new Response("bad signature", { status: 401 });
}
const body = JSON.parse(raw);
if (body.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
after(async () => {
const answer = await askModel(body.event.text); // the slow part
await postToSlack(body.event.channel, answer);
});
return new Response(null, { status: 200 }); // well inside 3 seconds
}
问题在于:after() 只能运行到路由的最长执行时间,不会更久。如果模型调用抛出异常,或者实例被回收,事件就没了。Slack 已经拿到了 200,不会再发一次,也没有任何地方记录过这个事件存在过。
Cloudflare Workers
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const raw = await req.text();
if (!(await verify(raw, req.headers, env))) {
return new Response("bad signature", { status: 401 });
}
ctx.waitUntil(handle(JSON.parse(raw), env)); // runs after the response
return new Response(null, { status: 200 });
},
};
问题在于:waitUntil 在响应之后最多只有 30 秒,由你传给它的所有任务共享,时间一到就会被取消。一次慢的模型调用放不进去。对于更长的任务,Cloudflare 自己给出的方案是 Queue 加一个消费者 Worker,这个方案是对的。我们的 Workers 指南讲了这个平台的其他坑。
Express 或任何常驻的 Node 服务器
app.post("/webhooks/slack", express.raw({ type: "application/json" }), (req, res) => {
if (!verifySlackSignature(req.body, req.headers)) return res.sendStatus(401);
res.sendStatus(200); // answer first
setImmediate(() => handle(JSON.parse(req.body)).catch(console.error));
});
问题在于:进程重启之前一切都好。一次部署、一次崩溃,或者调用中途因内存不足被杀掉,都会丢掉正在处理的一切。在 Serverless 平台上,响应一发出,进程还可能立刻被冻结。
Python (FastAPI)
from fastapi import BackgroundTasks, FastAPI, Request, Response
app = FastAPI()
@app.post("/webhooks/line")
async def line_webhook(request: Request, background: BackgroundTasks):
raw = await request.body()
if not verify_line_signature(raw, request.headers.get("x-line-signature")):
return Response(status_code=401)
background.add_task(handle_events, raw) # runs after the response is sent
return Response(status_code=200)
问题和 Express 一样:任务活在进程里。进程一重启,任务就没了,没有重试,也没有记录。
这四种写法的共同点
每一种都是在事件只存在于内存中的那一刻返回 200。发送方从此不再关心;如果你的后台任务失败了,没有第二次机会,事后也没有任何东西可查。
持久的版本是一个大家都熟悉的模式:应答之前先把事件写下来,然后让工作进程去处理,失败了就重试,并保留足够的记录,能看到哪些失败了,也能重放它们。这就是一个带死信列表和重放按钮的队列。它并不难写。可它又是一样要运行、要监控、出了事要被叫起来处理的东西,而且它并不是你的机器人要做的事。如果你想自己掌控这一层,我们的 Vercel 指南给出了先持久化、再应答的写法。
如果你不想自己运行队列
在处理程序前面放一个转发服务。你只需要在发送方那里把 Webhook URL 改成你的 AnyHook URL,别的都不用改。
- 发送方约 1 秒就能收到 200。这个 200 发出之前,事件已经保存好了
- 随后事件投递到你的处理程序,投递超时为:Free 套餐 60 秒,Pro 套餐 120 秒,Scale 套餐 5 分钟,以你套餐的计算额度为限。一次模型调用放得进去,所以处理程序可以直接在请求里把活干完,干完再应答
- 如果你的处理程序失败或超时,AnyHook 会按自己的重试计划重试:5 分钟、30 分钟、3 小时、18 小时,之后每天一次;Free 套餐最多 3 次投递尝试,Pro 5 次,Scale 10 次
- 每个事件都保存在可搜索的日志里(Free 套餐 3 天,付费套餐更久),修好 bug 之后,任何一个都可以重放
- AnyHook 会替你应答 Slack 的
url_verification验证请求,所以 URL 第一次就能保存成功
投递是至少一次(at-least-once)的。处理程序如果已经把活干完了,却在投递超时之后才应答,就会再次收到这个事件,所以要让它幂等:凡是 X-AnyHook-Delivery 请求头(即事件 ID,每次重试都相同)已经处理过的,一律跳过。发送方自己的 ID 也会一并转发,比如 Slack 的 event_id 或 GitHub 的 X-GitHub-Delivery。重复事件与幂等性一文给出了具体做法。
转发服务做不到的,是在发送方需要真正的响应体时替你回答:
- Slack 的斜杠命令和交互按钮要求回复写在响应里,或者先快速应答。用你自己的端点返回一个空的 200 应答它们,再把真正的回答发到 Slack 给出的
response_url,它在 30 分钟内最多接收五条消息 - Discord 的交互要求在 3 秒内给出初始响应。先回一个延迟响应(类型 5),等模型的回答到了再编辑它;交互令牌在 15 分钟内有效
- Twilio 的语音和短信 Webhook 要求返回 TwiML。立即用 TwiML 应答,慢的回答通过 REST API 发送
事件类 Webhook(只需要一个 2xx 的那种)才是转发服务的用途:Slack 的 Events API、LINE、Shopify、GitHub、HubSpot、Stripe,以及其他大多数平台。
测一测你的处理程序要多久
你不用去猜你的处理程序落在这条线的哪一边。Webhook 超时检测工具会向你的端点发送一个测试请求,计时,并把结果和每个发送方的时限放在一起。它会测两次,所以安静一段时间后第一次请求偏慢,会如实显示为冷启动。
常见问题
为什么 Slack 说我的机器人超时了,或者为什么它回复了两次?
Slack 等 2xx 只等 3 秒。如果你的处理程序在应答之前调用模型,耗时就会超过这个时间,Slack 记一次失败并再次发送事件,两次运行都会发出回复。先应答,再用 chat.postMessage 发送回复,并丢弃已经见过的事件。
在 Vercel 上,我能直接返回 200 然后继续处理吗?
可以,用 next/server 的 after()(或者 waitUntil),任务会一直运行到路由的最长执行时间为止。但它不持久:任务失败或者实例被回收,事件就丢了,发送方也不会重发。用来发通知没问题,用在用户会发现缺失的事情上就不对了。
LINE 等 Webhook 响应会等多久?
两秒。机器人服务器 2 秒内没有应答的 Webhook,LINE 会在错误统计里记为 request_timeout,并建议异步处理。Webhook 重发默认关闭;在频道的 Messaging API 设置里打开它。
在前面加一个队列,会让我的机器人回复变慢吗? 不会。转发服务在事件保存后立即转发,所以你的处理程序开始运行的时间,和原来差不多。变化在于,模型思考期间,发送方不用再一直占着连接。只有投递失败时,重试才会增加时间,而没有重试,那个事件本来就丢了。
如果我的处理程序要跑 60 秒以上怎么办? 在 Free 套餐下,AnyHook 会等你的处理程序应答 60 秒;Pro 套餐等 120 秒,Scale 套餐等 5 分钟,以你套餐的计算额度为限。比这更长的任务,在把任务放进队列后立即返回 202,然后在你这边完成。转发服务会把 202 计为已投递。
这对 Discord 机器人和 Slack 斜杠命令有用吗?
通过转发服务不行。两者都需要在 3 秒内返回特定的响应体,只有你自己的代码能给。按上文所说,使用 Discord 的延迟响应和 Slack 的 response_url。Slack 的 Events API 只需要一个 2xx,可以放在转发服务后面。
我的处理程序会收到同一个事件两次吗? 有可能,不管有没有转发服务:发送方会重试,你的处理程序失败时 AnyHook 也会重试。把每个处理程序都当作至少一次投递来处理,跳过已经处理过的 ID。