🚥

現在開発中の「SpeedLimiter」Gemの紹介

2023/12/19に公開

この記事はRuby Advent Calendar 2023 19日目の記事です。

こんにちは、Seibiiで主にRailsの開発を担当している向山です。

今回は現在開発中のRuby Gem「SpeedLimiter」について紹介します。

SpeedLimiterとは?

SpeedLimiterのイメージ

「SpeedLimiter」は複数のプロセス、スレッド、サーバー間での実行回数を制限することができるGemです。
主にActiveJobやバッチ処理などでAPIのレート制限に引っかからないように制限をかけることができます。

たとえば下記のようにすることで、1秒間に10回までのリクエスト制限できます。

SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1) do |state|
  puts state #=> <SpeedLimiter::State key=server_name/method_name count=1 ttl=0>
  http.get(path)
end

第一引数は制御をかけたい処理の名前です。
複数箇所で制限をかけたい場合は同一の名前であれば制限を共有できます。

開発の背景

APIなどのアクセス制限にかからないようにAPIをコールする方法はいくつか方法があります。

  1. sleep などで実行回数を制限する
  2. とりあえず実行してエラーになったらリトライする
  3. Sidekiq::Limiter(Sidekiq Enterprise)を利用する
  4. Redisなどのキャッシュを利用して実行回数を制限する

1の方法は言わずもがなですが、ActiveJobなどを使って複数のスレッドで実行した場合には意味がありません。

Seibiiでは今まで2の方法でなんとかなっていたのですが、直近Slackなどの通知が増えてきており制限に定期的に引っかかるようになってきたので見直しをすることにしました。

3の方法はSidekiq Enterpriseのライセンスが現在ないことと、Sidekiq外の処理で制限をかける場合に機能が不足していそうだったので採用を見送りました。

SpeedLimiterは4の方法を利用しています。

技術的な詳細

現在はリファクタリングをして、複数のクラスに処理を分けていますが当初のv0.0.1のコードは下記のような実装でした。
大枠の実装は現在も同様となっており、Redisで実行回数を数え制限を超えた場合は sleep で待機するというシンプルな実装です。

https://github.com/seibii/speed_limiter/blob/v0.0.1/lib/speed_limiter.rb

現在はこれに加えて、制限がかかった際に引数のProcを実行する機能や実行状況などを引数blockに渡す機能などを追加しています。

使用例

具体的な使用例はREADMEにも記載していますが、下記のような感じで利用できます。

result = SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1) do
  http.get(path)
end
puts result.code #=> 200

on_throttled を指定することで制限を超えた際に実行する処理を指定できます。

on_throttled = proc { |state| logger.info("limit exceeded #{state.key} #{state.ttl}") }
SpeedLimiter.throttle('server_name/method_name', limit: 10, period: 1, on_throttled: on_throttled) do
  http.get(path)
end

ActiveJobなどを利用する場合、あまり長い時間 sleep するとジョブが詰まったりタイムアウトしてしまう可能性があります。
専用のキューを作りキューに対して並行処理数を制限するといった方法もありますが、on_throttled を利用し、一定時間後に再度ジョブを実行するようにするといった使い方ができます。

class PostSlackMessageJob < ApplicationJob
  def perform(*args)
    on_throttled = proc do |state|
      raise Slack::LimitExceeded, state.ttl if state.ttl > 5
    end

    SpeedLimiter.throttle("slack", limit: 20, period: 1.minute, on_throttled: on_throttled) do
      post_slack_message(*args)
    end
  rescue Slack::LimitExceeded => e
    self.class.set(wait: e.ttl).perform_later(*args)
  end
end

今後の展望

現在は開発中ということで、まだまだ機能不足やインターフェイス設計的に微妙な部分がありそうだなと思っています。
実際に利用しながら改善していく予定です。

直近対応したいなと思っている機能としては、下記のようなものがあります。

  • 制限設定の共有
    先日のリファクタリングで大体できていると思うのですが、制限情報をオブジェクトとして共有し複数箇所で同じ制限共有しやすくしたいと考えています。
  • 例外をキャッチした際に制限を行う機能
    Slackなど一部のAPIは明確な制限が明記されておらず、APIエラーをきっかけに制限をかけるのが最適な場合があります。
    そのような場合には例外をキャッチしてAPIの戻り値を元に適切に制御をかける必要があります。
  • 例外発生時にリトライを行う機能
    APIは多かれ少なかれエラーを返すことがあります。「例外をキャッチした際に制限を行う機能」と合わせて簡単にエラー発生時のリトライが行えるようにしたいと思っています。

その他、フィードバック、バグ報告、コードのコントリビューションを歓迎します!
GitHubのリポジトリはこちら: https://github.com/seibii/speed_limiter

会社紹介と求人情報

Seibiiでは出張整備をはじめとする自動車アフターマーケット市場に新しい価値を生み出すことを目指すスタートアップです。
現在、Ruby開発者を含むいくつかのポジションで採用を行っています。詳細は採用サイトでご確認ください。

Discussion