zudo-slack-wisdom
GitHub リポジトリ

検索したい単語を入力

いつでも検索バーを開ける

Workers での Events API

Worker 上で動く Events API エンドポイントの url_verification ハンドシェイク、素早い ack、リトライ、イベントの重複排除

Request URL の登録

Event Subscriptions は api.slack.com のアプリ設定の中にある。有効化したら、Slack に単一の Request URL -- Worker 上のルート -- を指定する。すると Slack は後述のハンドシェイクを 送り、イベント種別を購読すればそこに実際のトラフィックも流し始める。 Request URL は大文字小文字を区別する

url_verification のハンドシェイク

Slack は Request URL を有効化する前に、そのエンドポイントを本当に管理しているかを確認する ため、一度だけチャレンジを POST してくる。

{
  "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
  "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
  "type": "url_verification"
}

Worker は HTTP 200 で応答し、challenge の値をそのまま返さなければならない。プレーン テキスト、フォームエンコード(challenge=...)、JSON({"challenge": "..."})のいずれの 応答も受け付けられる (リファレンス)。最小限の ハンドラーは、他の何よりも先に type で分岐する。

const body = await request.json<{ type: string; challenge?: string }>();

if (body.type === "url_verification") {
  return Response.json({ challenge: body.challenge });
}

先に署名を検証する

このハンドシェイクのリクエストも、他のすべてのイベントと同じ方式で署名されている。この分岐が 走る前に署名検証を通すこと -- ここを飛ばすと、Request URL を推測できた者は誰でもエンドポイント を叩いて探れてしまう。X-Slack-Signature / X-Slack-Request-Timestamp の検証についてはリクエストの検証を参照 (出典)。

素早く ack し、処理は waitUntil

購読を始めると、条件に合うイベントはそれぞれ個別の POST として届き、Slack は 3 秒以内の 2xx 応答を期待する (出典)。Worker でこれを守るとは、Slack Web API の 呼び出しや D1 への書き込み、AI による要約が終わるより先にレスポンスを返すということだ -- そうした処理はレスポンス前にインラインで await せず、ctx.waitUntil() に渡す。パターンは 3 秒以内の ackを参照。

リトライとイベントの重複排除

3 秒の枠を逃してもイベントが失われるわけではない。Slack は最大 3 回リトライする。1 回目は ほぼ即座に、2 回目は約 1 分後、3 回目(最後)は約 5 分後だ (出典)。つまり応答が 1 度遅かったり不安定だった りするだけで、同じイベントが最大 4 回エンドポイントに届きうる(最初の配信 + 3 回のリトライ)。

リトライには 2 つのヘッダーが付く (出典)。

  • X-Slack-Retry-Num -- 試行回数。123 のいずれか。

  • X-Slack-Retry-Reason -- リトライの理由。http_timeout(3 秒以内に 2xx が返らなかった)、 connection_failedssl_errorhttp_errortoo_many_redirectsunknown_error

ただし重複排除にこのリトライヘッダーを使ってはいけない。これらが示すのはこの配信がリトライ であることだけで、その裏にあるイベントをすでに処理したかどうかは分からないからだ。すべての イベントエンベロープには安定した event_id が入っているので、重複排除の判定はそちらを キーにする(短い TTL の KV か D1 の行で十分だ)。すでに処理済みの event_id にはエラーを 返さず 2xx を返す。エラーを返してもリトライを 1 回消費するだけで、結果は変わらない。

配信の識別子 vs. ドメインの識別子

上記の event_id による重複排除が抑えているのは、同じ配信エンベロープがエンドポイントに 2 回届くケースだけだ。そのエンベロープが表す裏の事実については何も語らない。あるユーザーが 同じリアクションを外して付け直せば、reaction_added イベントごとに新しい event_id が 発行される -- エンベロープは 2 つでも、反応すべきアクションは 1 回だ。さらに push と poll を 併用するハイブリッドな設計では、同じ事実が第 3 の形でも現れうる。現在の状態を読むポーリングの 巡回が、イベントを一切介さずにそれを観測するケースだ。

エンベロープのキーの下に、もう 1 段キーを重ねる。event_id への UNIQUE 制約は 1 つの エンベロープの再配信を抑える。事実そのものへの 2 つ目の UNIQUE 制約 -- リアクションなら (channel, message_ts, reaction, user) のような組 -- は、別々のエンベロープと poll 経路の 観測を、1 つの副作用へと畳み込む。

CREATE TABLE reaction_dedupe (
  event_id TEXT PRIMARY KEY,
  channel TEXT NOT NULL,
  message_ts TEXT NOT NULL,
  reaction TEXT NOT NULL,
  user_id TEXT NOT NULL,
  UNIQUE (channel, message_ts, reaction, user_id)
);

このテーブルへの INSERT ... ON CONFLICT DO NOTHING は、(channel, message_ts, reaction, user_id) の組ごとに 1 回しか成功しない。どの event_id で届いたかにも、 そもそもイベントとして届いたかどうかにも関係なく。

「いつかは必ず実行されるべき」副作用のための永続台帳

重複を取りこぼしても害がないなら、重複排除して捨てるだけで十分だ。一部の副作用はその逆で、 最初の試行を取りこぼす方が許容できない失敗になる。配信の直近の試行がまるごと失敗しても、 その作業はいつかは実行されなければならないからだ。

そこで永続的な台帳へと格上げする。1 回の D1 の insert で event_id と実行すべき作業を アトミックに記録し(INSERT ... ON CONFLICT (event_id) DO NOTHING)、200 を ack してから、 配信をバックグラウンドの作業として試みる。失敗または中断した試行があっても、その台帳の行は 未配信のまま残る -- 消えはしない -- ので、cron のスイープがスケジュールに沿ってテーブルを 突き合わせ、未配信のままの行を再試行する。即座の waitUntil() による試行は多くの場合を カバーする最適化であり、それが届かなかった場合の正しさを担保するのが cron の役目だ。上記の 事実の識別子キーはここでも効いていて、同じ事実に対する cron の再試行と新しいイベントが 同時に発生しても、1 つの台帳の行に畳み込まれ、作業が二重に実行されることはない。

この仕組みが閉じるものと閉じないものは正直に書いておく。外部呼び出しの成功と、台帳の行を 配信済みとマークする処理は、1 つのアトミックな操作ではなく別々の 2 つの操作だ。その間に クラッシュが起きれば、実際には副作用が既に発生していても行は未配信に見えてしまい、cron が それを再試行する -- つまりこれは、送信先の副作用に対して at-least-once(少なくとも 1 回)を保証するものであり、exactly-once(正確に 1 回)ではない。台帳さえあれば 1 回だけの 実行が安全になると思い込むのではなく、副作用そのものを重複呼び出しに耐えるよう設計する (あるいは下流で重複排除する)こと。この台帳 + cron と同じ形をより詳しく扱った outbox パターンについては、3 秒以内の ackを参照。

自分の bot のイベントは subtype ではなく bot_id で判定する

グラニュラー権限(現行の xoxb)でインストールされた bot が投稿するメッセージにはbot_id は付くが、subtype はまったく付かない -- subtype === "bot_message" だけを 見るフィルターはそうした投稿をそのまま素通りさせてしまい、自分が書き込んだチャンネルを 読む bot が自分自身をループで起動しかねない。bot_id の有無をメッセージが自分の bot の ものかどうかの一次判定にし、subtype のチェックはそれを設定し続けるクラシックトークンの アプリ向けフォールバックとしてのみ残すこと。同じルールを、ライブのイベントストリームだけで なく conversations.historyconversations.replies でメッセージを読み返すときにも 適用する。

自分の bot 自身が呼ぶ reactions.add も、同じ reaction_added の購読を通って返ってくるが、 リアクションイベントにはフィルターできる bot_id が付かない -- 自分の bot のユーザー ID を (トークンとは別に)設定に持たせておき、イベントの user フィールドと突き合わせること。

購読スコープとイベント種別

イベントへのアクセスも、他のすべてと同じ OAuth スコープの仕組みに乗っている (出典)。 イベントリファレンスの各イベント種別には、それを 許可するスコープが記載されている -- file_created なら files:readreaction_added なら reactions:read、といった具合だ。配信を実際に 認可するのはスコープなので、トークン、スコープ、OAuth の最小スコープ原則に従い、イベントハンドラーが実際に使うスコープだけを要求すること。

チーム単位のイベントには、ワークスペースあたり・アプリあたりで直近 60 分間に 30,000 配信という 上限もある。これを超えると、購読したトラフィックの代わりに app_rate_limited イベントが届く (出典)。

Revision History

作成更新