你的機器人在開發環境運作得很完美,到了正式環境,卻只是有時候會回。使用者回報它「通常」會回覆,或是同一則訊息回了三次,或是整個下午都沒聲音,然後自己又恢復了。
這三種都是同一個 bug。Slack 給你三秒,你的處理程式花了四秒,之後發生的一切,都是 Slack 的重試機制碰上一個沒預料到重試的處理程式。
重點摘要
- 「你的 App 應該在三秒內,以 HTTP 2xx 回應事件請求」
- Slack 會重試三次:幾乎立刻一次,1 分鐘後一次,再來是 5 分鐘後。
- 不冪等的處理程式,會把這些重試變成大家都看得到的重複訊息。
- 對你確定處理不了的事件,在非 200 的回應加上
x-slack-no-retry: 1,就能停止重試。 - 60 分鐘內有超過 95% 的傳遞失敗,Slack 就會停用你的事件訂閱,並寄信給 App 的擁有者。
三秒做不了多少事
看看一個常見的處理程式,從收到 message.channels 到準備好回覆之間做了什麼。它驗證簽章,查出這是哪個工作區,從資料庫讀一些狀態。然後,在 2026 年,機器人只要做點有意思的事,就會呼叫模型。
前三件事,順利的話大概 200 毫秒。第四件要 30 秒。
這和付款 Webhook 遇到的時間尺度落差是同一回事,差別在於這裡的回應時限是公開寫明的三秒(每個發送端的回應時限,都整理在我們的 Webhook 服務商參考資料),而且後果看得見,不是無聲無息。沒有人會盯著一個 payment_intent.succeeded 失敗,但每個人都會看到機器人沒回應。
適用於所有發送端、所有你可能使用的託管平台的通用解法,寫在你的 Webhook 處理程式呼叫 LLM;逾時檢查工具則會拿 Slack 的三秒,替你的處理程式計時。Slack 另外還有一個專屬的逃生口,讓它比多數服務商好處理得多。
Slack 給你、Stripe 沒給的技巧
Slack 把確認收到事件和回應使用者分開。你先立刻回 200,之後再用 chat.postMessage,或打互動 payload 帶的 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 收到兩次。
沒人在用的標頭
有些事件永遠不會成功:工作區已經移除你的 App、頻道已經封存、payload 參照的物件已經被你刪掉。重試這些事件會浪費三次傳遞,更重要的是,會把你的失敗率推向讓事件訂閱被關閉的門檻。
Slack 讓你在每次回應時選擇不要重試:「在你非 200 OK 的回應中,提供這個 HTTP 標頭和值:x-slack-no-retry: 1」。
if (!workspace) {
return new Response("unknown workspace", {
status: 410,
headers: { "x-slack-no-retry": "1" },
});
}
只用在不可重試的錯誤上。資料庫逾時不屬於這種,你會需要那次重試。
失敗門檻
把一個變慢的下午變成服務中斷的,就是這部分。
「如果你的 App 在 60 分鐘內,有超過 95% 的傳遞嘗試落入這些失敗情況的任意組合,你的 App 的事件訂閱會被暫時停用。」
發生這種情況時,Slack 會寄信給 App 的建立者和擁有者。這只有在那個信箱的主人還在公司、而且還在看信時才有用。這和 Shopify 的緊急開發者信箱是同一個陷阱,詳見 Shopify Webhook 訂閱被刪除了:你唯一收到的警告,被送到一個設定時選好、之後再也沒人檢查的信箱。
值得記住的是:一小時內 95%,不是你會注意到的緩慢惡化。一次弄壞簽章驗證的部署,會立刻達到 100%,一小時後事件訂閱就關了。機器人不會報錯,它只是再也收不到任何東西,從內部看,就和一個安靜的頻道一模一樣。
機器人時好時壞,怎麼除錯
依序檢查這些。
回應時間,而不是錯誤率。一個 2.8 秒的處理程式,能通過你寫的每個測試,但只要資料庫稍微忙一點就會失敗。量 p99,不要量平均值,因為讓 Slack 逾時的就是 p99。
冷啟動,如果你用的是無伺服器。三秒的回應時限被冷啟動吃掉兩秒,其他所有事情只剩一秒。在 Vercel 上接收 Webhook,不讓事件遺失說明了怎麼讓路由的 import 圖保持精簡,在這裡,這比任何其他做法都更能把時間要回來。
那些重複是不是 Slack 的重試。第一則訊息之後六分鐘出現的重複訊息,是 Slack 的第三次重試,不是你發訊息邏輯的 bug。從時間點就分辨得出來。
事件訂閱是否仍然啟用。如果一切突然同時安靜下來,log 裡也沒有任何變化,在除錯其他東西之前,先去看 App 的 Event Subscriptions(事件訂閱)頁面。
AnyHook 的角色
三秒是傳輸層的限制,而 Webhook 中繼服務(webhook relay)取代的正是傳輸這一段。
把 Slack 的 Request URL(請求網址)指向 in.anyhook.net/you/slack。AnyHook 在邊緣節點驗證並儲存事件,大約一秒就回 200,不管你的處理程式是熱的、冷的,還是正在著火。滿足 Slack 三秒計時的,是一個永遠只做兩件事的邊緣 worker,所以重試序列幾乎不會開始,95% 的門檻也從來不會接近。
- 依方案不同,在運算額度內,你的處理程式有 60 到 300 秒可用,而不是三秒。
- 每個事件都在傳遞前儲存,所以部署期間的事件只需要一次重播,而不是一堆使用者的疑問。
- 傳遞到你端點的重試採用指數退避,持續時間遠比 Slack 六分鐘的序列長。
- 連續失敗 1、5、20 次時發出失敗警示,遠在任何數字到達 Slack 會採取行動的門檻之前。
AnyHook 會在邊緣節點回應 url_verification 握手,如果你提供了簽章密鑰,會先檢查 Slack 的簽章,而且不會把任何東西轉送給你的處理程式。
結論
Slack 的三秒,是所有主要發送端裡最緊的回應時限,也是少數錯過時會被產品使用者看見的。永遠先確認收到,再回覆。在做任何會讓人看到兩次的事情之前,先用 event_id 去重。確定事件已經沒救時,送出 x-slack-no-retry: 1。另外,去確認 App 的擁有者登記的是哪個信箱,因為事件訂閱被關掉時,通知就是寄到那裡。