👀

スケーラビリティを考慮したSlackアプリケーション開発 APIレート制限について抑えるべきこと

2021/02/19に公開

TL;DR

  • SlackAPIのレート制限について、大量にAPIをコールする可能性のあるアプリの開発に当たり、調査したことをまとめます
  • chat.postMessageを例に、APIのRate Limitを超えた場合にどう備えるかを考えます
  • Slack APIのRate Limit周りの仕様で調べてわかったことを書きます
  • 結論、SDK使えばヨシ!

Slack APIのRate Limit仕様

SlackAPIにはRateLimitが存在しており、これを超える頻度でコールされたAPIの動作は保証されません。

アクセス回数の計算方法

RateLimitは、workspaceのアプリケーションごと(つまりアクセストークンごと)に計算され、エンドポイントによってLimitが変わります。

詳細は以下に示されている通りです。(ただしこれは変更される場合があるとドキュメントに明記されています)
https://api.slack.com/docs/rate-limits#rate-limits__overview
postMessageは、1回/1秒呼び出すことが可能で、それ以上の頻度はバーストとして認識されます。
バーストしても、体感的には30~40件程度であれば送信されますが、とはいえ正しく処理される保証はありません。
バーストすることを想定するなら、ハンドリングすべきです。

RateLimitに達したとき(postMessage以外の場合)

RateLimitに達して期待する処理が完了しなかった場合、APIは以下のようなレスポンスを返します。(Webhookもこれは同様)

HTTP/1.1 429 Too Many Requests
Retry-After: 30

ここで、Retry-Afterは、再試行出来るようになるまでの秒数です(ミリ秒ではない)。
つまり上記の場合、同一workspaceで同一APIをコールするには、あと30秒待機する必要があるということになります。

SDKを利用せずに自前で実装したい場合は、429を受け取ったときに、Retry-Afterを評価して、その秒数分待機するようにすればハンドリングできます。

RateLimitに達したとき(postMessageの場合)

上述したとおり、1秒に1件以上のメッセージをAPI経由で送信することは原則できないとされています。
とはいえ、少しであれば、RateLimitを超えた頻度でリクエストされてバーストしても処理はされますが、多数のリクエストに渡ってそれを超えるようであれば、制限されます。
体感では、1秒間に100回リクエストしたときは、半分くらいは429が返ります。
(ちゃんと計測はしていないです。あまりRateLimitを無視してリクエストを送信しすぎると、アクセストークンが永久に無効になる可能性があるようです)

postMessageでは、Retry-Afterは当然1です。

RateLimit対策

まず前提として、可能な限りAPIの呼び出しを行わないで済むよう設計するべきです。
ユーザー一覧やチャンネル情報など、キャッシュしたりできるようなものはキャッシュします。
しかし、postMessageは送信したいときに都度コールする必要があります。

自前で実装する場合

上述したとおり、Retry-Afterを参照することで可能です。
ざっくり以下のようにすれば良いと思います。

const res = sendReq({ ...params });
if (res.status === 429) {
	const retryAfter = res.headers['retry-after'];
	queue() // 処理をキューします
	wait(retryAfter) // retryAfter秒待ちます
	resume() // 処理を再開します
} else {
	return 'success!'
}

SDKを利用する場合

SDKを利用することで、SDK側でうまくRateLimit超過時のハンドリングをしてくれます。
特に理由がなければSDKを利用するべきです。

以下はハンドリング部分の実態です。

        if (response.status === 429) {
          const retrySec = parseRetryHeaders(response);
          if (retrySec !== undefined) {
            this.emit(WebClientEvent.RATE_LIMITED, retrySec);
            if (this.rejectRateLimitedCalls) {
              throw new AbortError(rateLimitedErrorWithDelay(retrySec));
            }
            this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`);
            // pause the request queue and then delay the rejection by the amount of time in the retry header
            this.requestQueue.pause();
            // NOTE: if there was a way to introspect the current RetryOperation and know what the next timeout
            // would be, then we could subtract that time from the following delay, knowing that it the next
            // attempt still wouldn't occur until after the rate-limit header has specified. an even better
            // solution would be to subtract the time from only the timeout of this next attempt of the
            // RetryOperation. this would result in the staying paused for the entire duration specified in the
            // header, yet this operation not having to pay the timeout cost in addition to that.
            await delay(retrySec * 1000);
            // resume the request queue and throw a non-abort error to signal a retry
            this.requestQueue.start();
            throw Error('A rate limit was exceeded.');
          } else {
            // TODO: turn this into some CodedError
            throw new AbortError(new Error('Retry header did not contain a valid timeout.'));
          }
        }

これがWebClientクラスに組み込まれているため、単にインスタンスを生成してAPIをコールするだけで、ハンドルできます。

また、RateLimit発生時に、WebClientインスタンスはWebClientEvent.RATE_LIMITEDイベントを発行するので、それを受けてwarnのログを書き出すといったことも可能です。

以下はそれに関するSDKのspecです。

    it('should emit a rate_limited event on the client', function (done) {
      const spy = sinon.spy();
      const scope = nock('https://slack.com')
        .post(/api/)
        .reply(429, {}, { 'retry-after': 0 });
      const client = new WebClient(token, { retryConfig: { retries: 0 } });
      client.on('rate_limited', spy);
      client.apiCall('method')
        .catch((err) => {
          assert(spy.calledOnceWith(0))
          scope.done();
          done();
        });
    });

また、SDKを利用するがRateLimit時に勝手に待機させたくないときは、以下のようにインスタンスを生成すれば良いです。

const web = new WebClient(token, { rejectRateLimitedCalls: true });

ただ、このオプションはRetry-Afterの値が大きくなる得る場合に利用することが多いと思われます。
postMessageは1秒待機さえすれば、あとは多少バーストしても処理されるため、このオプションを有効化することはほぼないかと思います。

参考

https://api.slack.com/docs/rate-limits#rate-limits__events-api
https://medium.com/slack-developer-blog/handling-rate-limits-with-slacks-apis-f6f8a63bdbdc
https://slack.dev/node-slack-sdk/web-api
https://github.com/slackapi/node-slack-sdk/blob/c53ff54b69551b092504166279452f2165ed5314/packages/web-api/src/WebClient.ts#L328-L352
https://github.com/slackapi/node-slack-sdk/blob/c53ff54b69/packages/web-api/src/WebClient.spec.js#L907-L920

Discussion