Rails 8.2の動的rate_limitで実現する階層別APIレート制限

注: この機能はRails 8.2で導入されます。main にマージ済みですが、まだリリースされていません。GitHubでソースを見る か、Gemfileをmainブランチに向けて試すことができます。


Rails 8.0以降、コントローラーで rate_limit マクロが使えるようになりました。ただし難点は、to:within: がハードコードされた数値でなければならず、階層別の料金プランを扱いにくかったことです。Rails 8.2では両オプションがprocやメソッド名を受け取れるようになり、Rack::Attack を持ち出さずに「Free 100/分、Pro 1k/時、Enterprise無制限」をついに実現できます。

Rails 8.2以前にrate_limitが静的だった理由

8.2以前、rate_limit は静的なものに限られていました:

class Api::BaseController < ApplicationController
  rate_limit to: 100, within: 1.minute
end

すべてのユーザーが同じ制限になります。それ以前にプラン別の階層を実現したい場合、選択肢は次のとおりでした:

  1. rate_limit を諦めて Rack::Attack に頼る。ただしユーザー単位のルックアップはRailsが current_user をロードする前に行われる。
  2. キャッシュでリクエスト数を数え、429を自分でレンダリングする before_action を手書きする。
  3. 階層ごとにコントローラーを1つ定義し、プランに基づいてサブクラスへルーティングする。

3つともRailsが既に持っているロジックを重複させるものでした。欠けていたのは、リクエストコンテキストから制限値を計算する能力でした。

Rails 8.2で動的なrate_limitがどう動くか

Rails 8.2(PR #56128)では、by: が既にそうだったように、to:within: も静的な値に加えてprocやメソッド名を受け取ります。ルールはシンプルです:

  • シンボル はコントローラー上の引数なしメソッドとしてディスパッチされます(send(:max_requests))。
  • procやlambda はコントローラー上のメソッドであるかのように実行されるので、current_userparamsrequestsession がすべてスコープ内にあります。procは引数を取りません。

それだけです。この記事の残りは、これを使って何が作れるかという話です。

パターン1: Free / Pro / Enterpriseの階層

マルチテナントのSaaS を作っているなら、これは待ち望んでいたパターンです。こう書けます:

class Api::BaseController < ApplicationController
  before_action :authenticate_api_user

  rate_limit to: :max_requests,
             within: :rate_window,
             by: -> { current_user.id }

  private
    def max_requests
      case current_user.plan
      when "enterprise" then 10_000
      when "pro"        then 1_000
      else                   100
      end
    end

    def rate_window
      current_user.plan == "free" ? 1.minute : 1.hour
    end
end

リクエストごとに、Railsはコントローラー上のこれら2つのメソッドを呼ぶので、制限はユーザーが今どのプランにいるかを反映します。by: のprocはカウンターをユーザーのIDでキー付けし、各ユーザーに専用のバケットを与えます。

階層別の制限をテストする

レート制限の設定をテストする方法は次のとおりです。対処すべきことは2つ: 実行間でキャッシュをクリアすること、そしてウィンドウをまたいでタイムトラベルすることです。

require "test_helper"

class Api::BaseControllerTest < ActionDispatch::IntegrationTest
  include ActiveSupport::Testing::TimeHelpers

  setup { Rails.cache.clear }

  test "free users hit 100 requests per minute" do
    sign_in_as users(:free_user)

    100.times { get api_widgets_url }
    assert_response :success

    get api_widgets_url
    assert_response :too_many_requests

    travel 1.minute do
      get api_widgets_url
      assert_response :success
    end
  end
end

並列テスト実行では、各ワーカーに専用のキャッシュを与えるか、:null_store に切り替えてカウントをアサートする代わりにマクロが設定されていることをアサートします。

パターン2: バースト + 持続的な制限

1時間に1,000リクエストは寛大に聞こえますが、ユーザーが3秒で全部撃ち尽くすまでの話です。持続的な制限の上にバースト制限を重ねます:

class Api::BaseController < ApplicationController
  before_action :authenticate_api_user

  rate_limit to: :sustained_limit,
             within: 1.hour,
             name: "sustained",
             by: -> { current_user.id }

  rate_limit to: :burst_limit,
             within: 10.seconds,
             name: "burst",
             by: -> { current_user.id }

  private
    def sustained_limit = current_user.plan == "pro" ? 1_000 : 100
    def burst_limit     = current_user.plan == "pro" ? 50 : 10
end

1つのコントローラーに rate_limit が2つ以上になると、name: が必須になります。これがキャッシュ内でどのカウンターがどれかをRailsに伝えるものです。

パターン3: 匿名 vs 認証済み

エンドポイントが公開されている場合、キーにする current_user がありません。IPと低めの上限まで下げます。by: のprocが両方のケースを一度に処理し、同じ手はAPIキーにも使えます:

class Api::PublicController < ApplicationController
  rate_limit to: -> { current_user ? 1_000 : 20 },
             within: 1.minute,
             by: -> { current_user&.id || request.remote_ip }
end

class Api::KeyAuthedController < ApplicationController
  rate_limit to: 5_000,
             within: 1.hour,
             by: -> { request.headers["X-Api-Key"] }
end

注意すべき落とし穴: Cloudflareや他のプロキシの背後にいる場合、config.action_dispatch.trusted_proxies が設定されていないと request.remote_ip はプロキシのIPになります。それを怠ると、すべての匿名リクエストが同じバケットにハッシュされ、レート制限はほぼ無意味になります。

パターン4: 親切な429レスポンス(計測付き)

デフォルトでは、制限に達すると ActionController::TooManyRequests が発生し、Action Dispatchがそれをプレーンな429として返します。HTMLならそれで構いませんが、APIクライアントは通常JSONと Retry-After ヘッダーを求めます。レスポンスをカスタマイズするついでに、同じ with: ブロックはAPM向けに ActiveSupport::Notifications イベントを発火させるのに良い場所です。マクロ自体はイベントを発行しないからです。

class Api::BaseController < ApplicationController
  rate_limit to: :max_requests,
             within: :rate_window,
             by: -> { current_user.id },
             with: -> {
               ActiveSupport::Notifications.instrument(
                 "rate_limit.exceeded",
                 user_id: current_user.id,
                 plan: current_user.plan,
                 controller: self.class.name
               )

               response.headers["Retry-After"] = rate_window.to_i.to_s
               render json: {
                 error: "rate_limit_exceeded",
                 plan: current_user.plan,
                 retry_after: rate_window.to_i
               }, status: :too_many_requests
             }
end

イニシャライザで rate_limit.exceeded をsubscribeすれば、Datadog、Honeycomb、あるいはトラフィックダッシュボードがどこにあろうとそこへ送るフィードが手に入ります。クライアント(またはスマートなリトライ戦略を使うバックグラウンドジョブ)は Retry-After を読んで、それに応じてバックオフできます。

Railsのrate_limitの注意点とエッジケース

マクロが前もって教えてくれないことがいくつかあります:

  • キャッシュストアの選択。 rate_limitRails.cache を使います。:memory_store はプロセスごとなので、2つのPumaワーカーはカウントを共有しません。:file_store はサーバーをまたいで機能しません。本番では :solid_cache_store:redis_cache_store:mem_cache_store を使ってください。いずれでも cache_store.increment はアトミックなので、複数のアプリサーバーにまたがる同時リクエストも追加の調整なしで正しく保たれます。

  • 固定ウィンドウであり、スライディングではない。 カウンターはウィンドウの境界でリセットされます。ユーザーは12:59:59に limit リクエストを、13:00:00にさらに limit リクエストを撃てます。それが気になるなら、より厳しいバースト制限を重ねてください(パターン2)。

  • プランのアップグレードは次のウィンドウから有効になる。 制限はリクエストごとに再計算されますが、進行中のウィンドウのカウントは引き継がれます。99/100のfreeユーザーがProにアップグレードしても、その分内の次のリクエストではまだ429になります。current_user.planby: のキーに含めると、本当に即時アップグレードが必要な場合にこれを回避できます。

  • procはコントローラーのバインディングで実行される。 current_userparamsrequest は機能しますが、rate_limit を書いた場所で定義したローカル変数は機能しません。コントローラーのメソッドとインスタンス状態に留めてください。

  • シンボルメソッドは引数を取らない。 send(name) でディスパッチされるので、値を返すだけにしてください。リクエストをパラメータとして受け取ろうとしないでください。

  • コントローラー横断の共有制限。 scope: を渡すと複数のコントローラーで1つのバケットを共有できます。「すべての書き込みが1つの制限にカウントされる」ような構成に便利です。

  • Rack::Attack にもまだ役割がある。 IPの許可/ブロックリスト、fail2ban風の不正利用ルール、そしてRailsがリクエストを起動する前に走る必要があるものには使い続けてください。rate_limit は、既にコントローラーのコンテキストがある、コントローラー単位・ユーザー単位・プランを考慮したポリシーに適したツールです。

  • パフォーマンス。 リクエストごとのメソッド呼び出しやproc呼び出しはマイクロ秒単位です。気に病む時間を無駄にしないでください。

FAQ

How do I rate-limit Rails API requests by user plan?

rate_limitto:within: オプションにメソッド名かprocを渡し、current_user.plan に応じた値を返すようにします。Rails 8.2はこれらをリクエストごとに評価するので、制限はユーザーの現在のプランを反映します。

Can I have multiple rate_limit declarations in one controller?

できます。それぞれに一意な name: を付けてください(例: name: "burst"name: "sustained")。nameがないと、同じコントローラーで複数宣言したときにRailsが例外を投げます。

Does Rails rate_limit work across multiple application servers?

キャッシュストアが共有されている限り、動作します。Solid Cache、Redis、Memcachedはいずれもアトミックなincrementをサポートするので、複数サーバーにまたがる同時リクエストも正しくカウントされます。デフォルトの :memory_store はプロセスごとなので、複数サーバーはもちろん複数のPumaワーカーをまたいでも機能しません。

What’s the difference between Rails rate_limit and Rack::Attack?

rate_limit はRailsのリクエストサイクルの内側で動くので、current_user、コントローラーのメソッド、ルーティングコンテキストにアクセスできます。Rack::AttackはRailsがリクエストを起動する前のRackレイヤーで動くため、IPの許可/ブロックリストや不正利用ルールに適したツールです。両方を使ってください: 低レベルのIPフィルタリングにはRack::Attack、ユーザー単位でプランを考慮したポリシーには rate_limit

How do I send a Retry-After header from rate_limit?

with: オプションでカスタムコールバックを渡し、その中で response.headers["Retry-After"] をセットして429レスポンスをレンダリングします。デフォルトの挙動は ActionController::TooManyRequests を発生させ、ヘッダーなしのプレーンな429になります。

本番での階層別レート制限

この変更により rate_limit はずっと便利になります。かつてmiddlewareの関心事(あるいはカスタムの before_action)だったものが、今や Api::BaseController に数行で収まります。階層別の料金プラン、バースト+持続的な制限、匿名のフォールバック、APIキーのバケット、可観測性のhook、それらすべてが今やマクロの中に住んでいます。

ソースを読みたいなら、実装は PR #56128 にあります。階層別のAPIを本番で出荷するなら、スマートなリトライ戦略と組み合わせて、クライアントがあなたが今返すようになった Retry-After ヘッダーを尊重するようにしましょう。そして、セッションユーザーではなくAPIトークンでキー付けするなら、ベアラートークン認証がこれとよく合います。