ボットは開発中は完璧に動きます。本番環境では、ときどきしか答えません。ユーザーからは、「たいていは」返事が来る、1 つのメッセージに 3 回返事が来た、午後のあいだずっと黙っていてひとりでに戻ってきた、といった報告が届きます。
3 つとも同じバグです。Slack が与えた時間は 3 秒で、ハンドラーは 4 秒かかりました。その後に起きることはすべて、Slack のリトライポリシーと、それを想定せずに書かれたハンドラーとの組み合わせで生じています。
TL;DR
- 「アプリは、イベントのリクエストに 3 秒以内に HTTP 2xx で応答する必要があります」
- Slack は 3 回リトライします。ほぼ直後、1 分後、そして 5 分後です
- 冪等でないハンドラーは、そのリトライを人の目に見える重複メッセージに変えます
- 200 以外のレスポンスに
x-slack-no-retry: 1を付ければ、処理できないとわかっているイベントのリトライを止められます - 60 分の間に配信の 95% 超が失敗すると、Slack はイベントサブスクリプションを無効化し、アプリのオーナーにメールを送ります
3 秒でできることは多くない
よくあるハンドラーが message.channels を受け取ってから返信できるようになるまでに、何をしているかを見てみます。署名を検証します。どのワークスペースかを調べます。データベースから状態を読みます。そして 2026 年のボットが何か面白いことをしているなら、モデルを呼び出します。
最初の 3 つは、調子が良い日ならせいぜい 200ms です。4 つ目は 30 秒です。
これは決済の Webhook で起きるのと同じ、時間のスケールの食い違いです。違うのは 2 点です。ここでは応答期限が公表された 3 秒であること(各送信元の応答期限は Webhook プロバイダー別リファレンスにまとめています)、そして結果が静かに起きるのではなく目に見えることです。payment_intent.succeeded の失敗を見ている人はいません。ボットが答えないところは、全員が見ています。
どの送信元にも、どのホスティング先にも通用する対処の全体像は、「Webhook ハンドラーが LLM を呼ぶ」で説明しています。タイムアウトチェッカーを使えば、ハンドラーの応答時間を Slack の 3 秒と比べて測れます。さらに Slack には専用の逃げ道があり、ほかの多くの送信元よりずっと楽に対処できます。
Stripe にはなく、Slack にはある技
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
}
その後、ワーカーは必要なだけ時間をかけ、言うことがまとまったら chat.postMessage を呼びます。ユーザーから見れば、ボットが考えているように見えます。ボットとしてはごく普通の振る舞いです。Slack から見れば、イベントには 40 ミリ秒で応答済みです。
処理に 1〜2 秒以上かかるなら、すぐに仮のメッセージを投稿して、後から更新します。「処理中です」と表示してから自分で書き換わるメッセージのほうが、3 秒間何も起きずにタイムアウトするより、ずっと良い体験です。
リトライが実際にもたらすもの
Slack のスケジュールは短く、具体的です。「1 回目のリトライはほぼ直後に送信されます。2 回目のリトライは 1 分後に試行されます。3 回目、つまり最後のリトライは 5 分後に送信されます。」
では、先に処理をして最後に 200 を返す、4 秒かかるハンドラーを考えてみます。Slack は 3 秒でタイムアウトします。ハンドラーは 4 秒で終わり、メッセージを投稿します。Slack はほぼ直後にリトライし、ハンドラーがもう一度動いて、もう一度投稿します。1 分後に 3 通目、5 分後に 4 通目が投稿されます。
ユーザーは 1 つ質問しただけなのに、6 分にわたって 4 つの答えを受け取りました。遅いボットというより、壊れたボットに見えます。
対処は、ほかと同じユニーク制約のパターンです。キーには 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 にリトライさせることになります。2 つのリトライが同じミリ秒に 2 つのインスタンスへ届く並行処理のケースは、「同じ Webhook が 2 回届いた」で詳しく説明しています。
誰も使わないヘッダー
決して成功しないイベントもあります。ワークスペースがアプリをアンインストールした、チャンネルがアーカイブされた、ペイロードが削除済みのオブジェクトを参照している、といった場合です。こうしたイベントのリトライは配信 3 回分の無駄です。さらに重要なことに、サブスクリプションが無効化されるしきい値へ失敗率を押し上げます。
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 に Webhook を削除された」で説明した、Shopify の緊急用開発者メールと同じ罠です。届く唯一の警告は、セットアップ時に選ばれて以来見直されていないアドレスに送られます。
覚えておくべきなのは、1 時間で 95% というのは、気づけるようなゆっくりした劣化ではないということです。署名検証を壊したデプロイは一瞬で 100% に達し、1 時間後にはサブスクリプションが止まっています。ボットはエラーを出しません。ただ何も受け取らなくなるだけで、内側から見ると静かなチャンネルとまったく同じです。
ときどきしか答えないボットのデバッグ
次の順に確認します。
まず、エラー率ではなく応答時間です。2.8 秒のハンドラーは、書いたテストにはすべて通り、データベースが少し混むたびに失敗します。平均ではなく p99 を測ってください。Slack がタイムアウトさせるのは p99 だからです。
次に、サーバーレスならコールドスタートです。3 秒の応答期限のうち 2 秒をコールドスタートに取られると、残りすべてに使えるのは 1 秒です。ルートの import グラフを小さく保つ方法は「Vercel で Webhook を取りこぼさずに受信する」で扱っています。ここでできることの中で、それが最も多くの時間を取り戻せます。
それから、その重複が Slack のリトライかどうかです。最初のメッセージの 6 分後に届いた重複は Slack の 3 回目のリトライで、投稿ロジックのバグではありません。タイミングを見ればどちらかわかります。
最後に、サブスクリプションがまだ有効かどうかです。すべてが一斉に静かになり、ログに何の変化もないなら、ほかのデバッグを始める前に、アプリの Event Subscriptions(イベントサブスクリプション)ページを確認してください。
AnyHook の位置づけ
3 秒は通信路の制約で、通信路こそ中継サービスが置き換えるものです。
Slack の Request URL(リクエスト URL)を in.anyhook.net/you/slack に向けます。AnyHook はエッジで検証と永続化を行い、ハンドラーが温まっていても、コールドでも、炎上中でも、約 1 秒で 200 を返します。Slack の 3 秒の時計を満たすのは、2 つのことしかしないエッジのワーカーです。そのためリトライの流れはほとんど始まらず、95% のしきい値に近づくこともありません。
- ハンドラーに使える時間は 3 秒ではなく、プランに応じて、そのコンピュート枠の範囲内で 60〜300 秒になります
- すべてのイベントは配信前に永続化されるので、デプロイ中に届いたものは、ユーザーからの問い合わせではなく再送で片づきます
- エンドポイントへの配信は指数バックオフでリトライされ、その期間は Slack の 6 分間の流れよりはるかに長くなります
- 失敗アラートは、連続失敗が 1 回、5 回、20 回に達したときに送られます。Slack が動くしきい値に達するよりずっと前です
AnyHook は url_verification のハンドシェイクにエッジで応答します。署名シークレットを渡してあれば、先に Slack の署名を検証します。ハンドラーには何も転送しません。
まとめ
Slack の 3 秒は、主要な送信元が課す応答期限の中で最も厳しいものです。そして、間に合わなかったことが製品を使う人たちに見えてしまう、数少ないケースの 1 つです。必ず、先に応答して後から返信してください。人が 2 回見ることになる処理の前に、event_id で重複排除します。イベントが死んでいるとわかっているときは x-slack-no-retry: 1 を送ります。そして、アプリのオーナーとしてどのアドレスが登録されているかを確認しに行ってください。サブスクリプションが止められたとき、通知が届くのはそこです。