Slack Events APIの再送仕様と回避方法まとめ(Serverless on AWS)

SlackのEvents API(Event Subscription)で盛大にドハマりしてしまったので、Events APIの再送の仕様をまとめて、本来のベストプラクティスに沿った実装と妥当な落とし所をご紹介します。
2020.05.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

どうも、もこ@札幌オフィスです。

最近Slackを利用したBotの開発をする機会がありまして、SlackのEvents API(Event Subscription)で盛大にドハマりしてしまったので、Events APIの再送の仕様をまとめて、本来のベストプラクティスに沿った実装と妥当な落とし所をご紹介します。

Slack Events APIとは?

従来のポーリング型のAPIとは違い、あらかじめ受信するURLとイベント(メッセージの送信、リアクションの追加など)を指定してあげる事で、ワークスペース上でイベントが発生した際にSlackがWebHookしてくれる物となります。

Serverlessなどの環境とよくマッチし、今回はAPI Gateway + Lambdaを利用したサーバーレス環境で検証を行っています。

Slackの再送仕様について

まず前提として、Slackでは大きく分けて6個のリトライ条件があります。

  • http_timeout イベントのレスポンスに3秒以上かかった
  • too_many_redirects 2回を超えるリダイレクトがあった(原文は「We'll follow you down the rabbit hole of HTTP redirects only so far. If we encounter more than 2, we'll retry the request in hopes it won't be that many this time.」、不思議の国のアリスにちなんだスラングらしいです)
  • connection_failed サーバーに接続できなかった
  • ssl_error SSLエラー
  • http_error StatusCode 2xx以外のレスポンスが返ってきた
  • unknown_errorよく分からない時のエラー

上記のいずれかにマッチした場合、下記のタイミングでSlackから3回再送され、最初のリクエストを含めると合計で4回のイベントがサーバーに送られます。

  • エラーを検知してすぐ( http_timeout の場合は最初にリクエストしたレスポンスを待たずに、3秒経ったら再送する(重要))
  • 1分後
  • 5分後

ここでとても重要なのは、 http_timeout は3秒経つとサーバーからのレスポンスの有無に関わらず即座に再送してくるので、3秒以内に2xxを返してあげないと重複して処理が実行されてしまいます。

エラーを再送して欲しくない場合

HTTP 2xx以外のステータスコードでレスポンスヘッダーに X-Slack-No-Retry: 1 を含むとイベントの再送はされないように指定できますが、 http_timeout は3秒経つとサーバーからのレスポンスの有無に関わらず即座に再送してくるので、レスポンスを返す前に新たなリトライリクエストが送信されてきます。

つまり、Slackに「5xx処理になったけど再送いらないよ」と伝えるためのもので、「このイベントの処理時間かかるから再送しなくていいよ」の意を伝える物ではありません。

3秒以上の処理がかかる時は?

Events APIではそもそもイベントを受け取ってから処理をして返すまでを同期的に実行することは推奨しておらず、イベント受け取り後にレスポンスと処理を分けたりキューイングをする事が推奨されています。

パターン1, キューイングする場合

バッチ的な物を流す場合、SQSを挟んであげるのが一般的なベストプラクティスとなるでしょう。

実行する処理により適したワークロードは異なりますが、Lambdaの最大実行時間である15分以下で終わるような処理でかつ、そんなにリソースを食わないスクリプト的な物であれば、SQSをイベントソースとした処理用のLambdaを用意するのがベストかもしれません。

下記はEvent Subscriptionの message.channels を受け取ってSQSにキューイングするサンプルです。

const AWS = require('aws-sdk');
const SQS = new AWS.SQS({ region: 'ap-northeast-1' });
const QueueUrl = 'https://sqs.ap-northeast-1.amazonaws.com/xxxxxxx/example';
const slackToken = process.env.SLACK_TOKEN

exports.index = async (event, context) => {
  const body = JSON.parse(event.body);
  if (!(body.token === slackToken)) return {"statusCode": 401, "body": "Missing Token"}

  try {
    const sendMessage = await SQS.sendMessage({ MessageBody: JSON.stringify(body), QueueUrl }).promise();
    console.log(sendMessage);
    return { statusCode: 202, body: JSON.stringify(sendMessage) };
  } catch (error) {
    console.log(error)
    return { statusCode: 502, body: { message: JSON.stringify(error) } };
  }
}

パターン2, LambdaからLambdaをキック

Lambda@Edgeなどでリクエストをイベントとして裏でゴニョゴニョやりたいけどタイムアウトの制限が厳しい、などの要件でたまに使うかと思いますが、同じような原理でイベントを受け取るLambdaから処理をするLambdaをInvokeして、処理を橋渡しすることもできます。

こちらは呼び出し元のLambdaで lambda:InvokeFunction ポリシーを付与し、LambdaからLambdaをキックしてデータを渡せばOKです。

※再帰呼出しにはくれぐれも注意しましょう。

const AWS = require('aws-sdk');
const lambda = new AWS.Lambda({ region: 'ap-northeast-1' });
const slackToken = process.env.SLACK_TOKEN

exports.index = async (event, context) => {
  const body = JSON.parse(event.body);
  if (!(body.token === slackToken)) return {"statusCode": 401, "body": "Missing Token"}
  const params = {
    FunctionName: 'example-lambda',  // 実行するLambda
    InvocationType: 'Event',
    Payload: JSON.stringify(body),
  };

  try {
    const invoke = await lambda.invoke(params).promise();
    console.log(invoke);
    return { statusCode: 202, body: JSON.stringify(invoke) };
  } catch (error) {
    console.log(error);
    return { statusCode: 502, body: { message: JSON.stringify(error) } };
  }
}

パターン3, そもそも再送を無視する方法

少々つらい感じですが、ヘッダーの X-Slack-Retry-Numがあるかを確認して再送の場合はreturnする事で重複を簡単に排除することができます。

const slackToken = process.env.SLACK_TOKEN

exports.index = async (event, context) => {
  const body = JSON.parse(event.body);
  if (!(body.token === slackToken)) return {"statusCode": 401, "body": "Missing Token"}
  // 再送かをチェック
  if (event.headers['X-Slack-Retry-Num']) {
    return { statusCode: 200, body: JSON.stringify({ message: "No need to resend" }) };
  }

  // メインの処理をしていく
  ...
}

なお、 X-Slack-Retry-Reason にエラー内容が記載されますので、タイムアウト以外のエラーでSlackが再送してきた場合は通常のリトライとして扱うなんて事も可能です。

const slackToken = process.env.SLACK_TOKEN

exports.index = async (event, context) => {
  const body = JSON.parse(event.body);
  if (!(body.token === slackToken)) return {"statusCode": 401, "body": "Missing Token"}
  // 再送かをチェック、http_timeoutなのかをチェック
  if (event.headers['X-Slack-Retry-Num'] && event.headers['X-Slack-Retry-Reason'] === "http_timeout") {
    return { statusCode: 200, body: JSON.stringify({ message: "No need to resend" }) };
  }

  // メインの処理をしていく
  ...
}

まとめ

ここまで3つのパターンをご紹介してきましたが、「Slackのアプリを作成して商用販売していて絶対にイベントを見逃せない」とかではない限りそこまで大規模なものを作る必要はないと思いますし、Slackをベースにミッションクリティカルな処理をしないと思うので、一番最後のheader確認して無視する形でも問題ないと思います。

誰かのお役に立てれば嬉しいです。もこでしたー。

参考

https://api.slack.com/events-api