你的机器人在开发环境里一切正常。到了生产环境,它有时回复,有时不回复。用户反馈说它“通常”会回复,或者对一条消息回复了三次,或者整个下午都没动静,然后自己又好了。
这三种其实是同一个 bug。Slack 给了你三秒,你的处理程序用了四秒,之后发生的一切,都是 Slack 的重试策略撞上了一个没考虑到重试的处理程序。
TL;DR
- “你的应用应在三秒内以 HTTP 2xx 响应事件请求”
- Slack 会重试三次:几乎立即一次,1 分钟后一次,5 分钟后再一次
- 不幂等的处理程序会把这些重试变成大家都看得见的重复消息
- 对于你明知处理不了的事件,在非 200 响应里带上
x-slack-no-retry: 1,就能停止重试 - 60 分钟内超过 95% 的投递失败,Slack 就会禁用你的事件订阅,并发邮件通知应用的所有者
三秒干不了多少事
看看一个典型的处理程序,从收到 message.channels 到准备好回复之间都做了什么。它验证签名。它查出这是哪个工作区。它从数据库里读一些状态。然后,如果这个机器人在 2026 年还算在做点有意思的事,它会调用一个模型。
前三步顺利的话大概 200 毫秒。第四步要 30 秒。
这和支付 Webhook 遇到的时间尺度不匹配是同一个问题,只不过这里的响应时限是公开写明的三秒(各个发送方的响应时限都在我们的 Webhook 平台参考里),而且后果是看得见的,而不是静悄悄的。没人会盯着一个 payment_intent.succeeded 失败。但机器人不回话,所有人都看着。
适用于所有发送方、所有托管平台的通用修复思路,写在你的 Webhook 处理程序调用了大模型(LLM)里,而超时检测工具可以拿 Slack 的三秒给你的处理程序计时。Slack 还有一个专门的退路,让它比大多数平台好对付得多。
Slack 给你、Stripe 不给你的那个技巧
Slack 把应答事件和回复用户分开了。你立即返回 200,之后再用 chat.postMessage 发出真正的回复,或者请求交互请求体里带的 response_url。
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);
// URL verification handshake, only during setup
if (body.type === "url_verification") {
return Response.json({ challenge: body.challenge });
}
await queue.publish(body); // durable, ~10ms
return new Response(null, { status: 200 }); // well inside 3s
}
然后 worker 需要多久就花多久,有话要说时再调用 chat.postMessage。在用户看来,这就是机器人在思考,机器人思考一下很正常。在 Slack 看来,这个事件在 40 毫秒内就得到了应答。
如果工作要花一两秒以上,就先立即发一条占位消息,再更新它。一条先写着“正在处理”、随后自己更新内容的消息,体验远好于三秒的沉默之后来一个超时。
重试实际上会对你做什么
Slack 的重试计划短而明确:“第一次重试几乎会立即发送。第二次重试会在 1 分钟后进行。第三次也是最后一次重试,会在 5 分钟后发送。”
现在设想一个先干活、最后才返回 200 的处理程序,它要花四秒。Slack 在第三秒超时。你的处理程序在第四秒完成,发出一条消息。Slack 几乎立即重试,你的处理程序再跑一遍,再发一条。一分钟后,第三条消息。五分钟后,第四条。
用户问了一个问题,六分钟内收到四个回答,这看起来不像一个慢吞吞的机器人,更像一个坏掉的机器人。
修复方法和其他地方一样,用唯一约束,以 Slack 的 event_id 为键:
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, 'slack') ON CONFLICT (event_id) DO NOTHING`,
[body.event_id],
);
if (rowCount === 0) return new Response(null, { status: 200 });
注意重复路径上返回的是 200。在这里返回错误,等于让 Slack 重试你刚刚成功忽略掉的东西。两次重试在同一毫秒落到两个实例上的并发情况,详见同一个 Webhook 收到了两次。
没人用的那个响应头
有些事件永远不会成功。工作区卸载了你的应用,频道被归档了,请求体引用了一个你已经删掉的对象。重试这些事件会浪费三次投递,更重要的是,会把你的失败率推向让订阅被关掉的那个阈值。
Slack 允许你按单个响应选择不重试:“在你的非 200 OK 响应中,提供这个 HTTP 头和值:x-slack-no-retry: 1”。
if (!workspace) {
return new Response("unknown workspace", {
status: 410,
headers: { "x-slack-no-retry": "1" },
});
}
只在不可重试的情况下用它。数据库超时并非不可重试,你需要那次重试。
失败阈值
就是这一部分,会把一个慢吞吞的下午变成一次服务中断。
“如果你的应用在 60 分钟内超过 95% 的投递尝试出现上述失败情况的任意组合,你的应用的事件订阅将被暂时禁用。”
发生这种情况时,Slack 会给应用的创建者和所有者发邮件。只有当这个邮箱的主人还在公司、而且还在看这个邮箱时,这封邮件才有用。这和 Shopify 的紧急开发者邮箱是同一个坑,见 Shopify Webhook 订阅被删除了:你收到的唯一一次警告,会被发到一个设置时填写、之后再也没人检查过的地址。
值得记住:一小时内 95%,不是那种你会注意到的缓慢恶化。一次搞坏签名验证的部署会立刻让失败率达到 100%,一小时后订阅就被关掉了。机器人不会报错。它只是什么都收不到了,从内部看,这和一个安静的频道完全一样。
排查一个时而回复、时而不回复的机器人
按顺序检查下面几项。
看响应时间,而不是错误率。一个耗时 2.8 秒的处理程序能通过你写的每个测试,但只要数据库稍微忙一点就会失败。衡量 p99 而不是平均值,因为 Slack 超时看的就是 p99。
如果你用的是 Serverless,看冷启动。三秒的响应时限里,冷启动占掉两秒,留给其他所有事情的只有一秒。在 Vercel 上接收 Webhook,不让事件丢失讲了如何让路由的导入依赖图保持精简,在这方面,它能帮你省回的时间比其他任何手段都多。
看这些重复是不是 Slack 的重试。第一条消息六分钟后出现的重复消息,是 Slack 的第三次重试,不是你发消息逻辑里的 bug。看时间就能分辨。
看订阅是否仍然启用。如果一切突然都安静下来,而你的日志没有任何变化,先去看应用的 Event Subscriptions(事件订阅)页面,再排查别的。
AnyHook 能做什么
三秒是一个传输层面的约束,而传输正是转发服务替你接管的部分。
把 Slack 的 Request URL(请求 URL)指向 in.anyhook.net/you/slack。AnyHook 在边缘节点验证并持久化,约 1 秒返回 200,不管你的处理程序是热的、冷的,还是已经烧起来了。满足 Slack 三秒时限的,是边缘节点上一个只做两件事的 Worker,所以重试序列几乎不会启动,95% 的阈值也永远不会被逼近。
- 你的处理程序有 60 到 300 秒,而不是三秒,具体取决于套餐,并以计算额度为限
- 每个事件在投递之前就已持久化,所以部署期间的空窗只需要一次重放,而不是用户来问一堆问题
- 向你的端点投递时按指数退避重试,持续时间远长于 Slack 六分钟的重试序列
- 连续失败 1 次、5 次和 20 次时发送失败告警,远在任何指标达到 Slack 会采取行动的阈值之前
AnyHook 在边缘节点应答 url_verification 握手,如果你给了它签名密钥,会先验证 Slack 的签名,并且不会向你的处理程序转发任何内容。
总结
Slack 的三秒是所有主流发送方中最紧的响应时限,也是少数几个超时会被你的产品用户直接看到的场景之一。永远先应答,再回复。在做任何人会看到两次的事情之前,先按 event_id 去重。当你知道这个事件已经没救了,就发送 x-slack-no-retry: 1。再去看看应用上登记的所有者是哪个邮箱,因为你的订阅被关掉时,通知就发到那里。