Limites de débit API à plusieurs niveaux dans Rails 8.2 avec rate_limit dynamique

Note : cette fonctionnalité arrive dans Rails 8.2. Elle est mergée sur main mais pas encore publiée. Vous pouvez voir le source sur GitHub ou l’essayer en pointant votre Gemfile sur la branche main.


Depuis Rails 8.0, vous disposez d’une macro rate_limit dans vos controllers. Le hic, c’est que to: et within: devaient être des nombres en dur, ce qui rendait la tarification par paliers maladroite. Rails 8.2 permet aux deux options d’accepter des procs ou des noms de méthode, vous pouvez donc enfin faire « Free 100/min, Pro 1k/h, Enterprise illimité » sans recourir à Rack::Attack.

Pourquoi rate_limit était statique avant Rails 8.2

Avant la 8.2, rate_limit était uniquement statique :

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

Chaque utilisateur recevait la même limite. Si vous vouliez des paliers par plan auparavant, vos options étaient :

  1. Laisser tomber rate_limit et recourir à Rack::Attack, où la recherche par utilisateur a lieu avant que Rails n’ait chargé current_user.
  2. Coder à la main un before_action qui compte les requêtes dans le cache et rend le 429 vous-même.
  3. Définir un controller par palier et router les sous-classes selon le plan.

Les trois dupliquaient une logique que Rails avait déjà. La pièce manquante était la capacité de calculer la limite à partir du contexte de la requête.

Comment fonctionne le rate_limit dynamique dans Rails 8.2

Dans Rails 8.2 (PR #56128), to: et within: acceptent des procs et des noms de méthode en plus des valeurs statiques, exactement comme by: le faisait déjà. Les règles sont simples :

  • Un symbole est dispatché comme une méthode sans argument sur le controller (send(:max_requests)).
  • Un proc ou un lambda s’exécute comme s’il s’agissait d’une méthode sur le controller, donc current_user, params, request et session sont tous dans la portée. Les procs ne prennent pas d’arguments.

C’est tout. Le reste de l’article, c’est ce que vous pouvez construire avec.

Schéma 1 : paliers Free / Pro / Enterprise

Si vous construisez un SaaS multi-locataire, c’est le schéma que vous attendiez. Voilà à quoi ça ressemble :

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

À chaque requête, Rails appelle ces deux méthodes sur le controller, donc la limite reflète le plan sur lequel l’utilisateur se trouve en ce moment. Le proc by: clé le compteur sur l’ID de l’utilisateur, donnant à chacun son propre seau.

Tester les limites par paliers

Voici comment tester les configurations de limite de débit. Deux choses à gérer : vider le cache entre les exécutions, et voyager dans le temps à travers la fenêtre.

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

Pour les exécutions de tests en parallèle, donnez à chaque worker son propre cache, ou basculez sur :null_store et vérifiez que la macro est configurée plutôt que de vérifier le décompte.

Schéma 2 : limites burst + soutenue

1 000 requêtes par heure paraît généreux jusqu’à ce qu’un utilisateur les envoie toutes en trois secondes. Superposez une limite de burst par-dessus la limite soutenue :

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

Dès que vous avez plus d’un rate_limit dans un controller, name: devient obligatoire. C’est ce qui indique à Rails quel compteur est lequel dans le cache.

Schéma 3 : anonyme vs authentifié

Si votre endpoint est public, il n’y a pas de current_user sur lequel cléer. Redescendez vers l’IP et un plafond plus bas. Le proc by: gère les deux cas d’un seul coup, et la même astuce fonctionne pour les clés d’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

Un piège à connaître : si vous êtes derrière Cloudflare ou un autre proxy, request.remote_ip sera l’IP du proxy à moins que config.action_dispatch.trusted_proxies ne soit défini. Sautez ça et chaque requête anonyme hashe vers le même seau, ce qui rend votre limite de débit à peu près inutile.

Schéma 4 : réponses 429 conviviales (avec instrumentation)

Par défaut, atteindre la limite lève ActionController::TooManyRequests, qu’Action Dispatch renvoie comme un 429 brut. C’est très bien pour du HTML, mais les clients d’API veulent généralement du JSON et un en-tête Retry-After. Tant que vous personnalisez la réponse, le même bloc with: est un bon endroit pour déclencher un événement ActiveSupport::Notifications pour votre APM, puisque la macro n’en émet pas d’elle-même.

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

Abonnez-vous à rate_limit.exceeded dans un initializer et vous avez un flux pour Datadog, Honeycomb, ou là où vivent vos tableaux de bord de trafic. Les clients (ou vos jobs en arrière-plan utilisant des stratégies de retry intelligentes) peuvent lire Retry-After et temporiser en conséquence.

Mises en garde et cas limites de rate_limit dans Rails

Quelques points que la macro ne vous dit pas d’emblée :

  • Choix du cache store. rate_limit utilise Rails.cache. :memory_store est par processus, donc deux workers Puma ne partageront pas les décomptes ; :file_store ne fonctionne pas entre serveurs. En production, utilisez :solid_cache_store, :redis_cache_store ou :mem_cache_store. Avec n’importe lequel d’entre eux, cache_store.increment est atomique, donc les requêtes concurrentes entre plusieurs serveurs d’application restent correctes sans coordination supplémentaire.

  • Fenêtre fixe, pas glissante. Les compteurs se réinitialisent aux frontières de fenêtre. Un utilisateur peut envoyer limit requêtes à 12:59:59 et limit autres à 13:00:00. Si ça vous dérange, superposez une limite de burst plus serrée (Schéma 2).

  • Les montées en gamme prennent effet à la fenêtre suivante. La limite est recalculée à chaque requête, mais le décompte de la fenêtre en cours est reporté. Un utilisateur free à 99/100 qui passe à Pro recevra quand même un 429 sur sa prochaine requête cette minute-là. Inclure current_user.plan dans la clé by: contourne ça si vous avez vraiment besoin d’une montée en gamme instantanée.

  • Les procs s’exécutent dans le binding du controller. current_user, params et request fonctionnent ; les variables locales définies là où vous avez écrit rate_limit ne fonctionnent pas. Tenez-vous-en aux méthodes du controller et à l’état d’instance.

  • Les méthodes symbole ne prennent pas d’arguments. Elles sont dispatchées via send(name), donc renvoyez simplement la valeur. N’essayez pas d’accepter la requête en paramètre.

  • Limites partagées entre controllers. Passez scope: pour faire partager un seau à plusieurs controllers. Pratique pour les configurations « toutes les écritures comptent contre une seule limite ».

  • Rack::Attack a toujours sa place. Gardez-le pour les listes d’autorisation/blocage d’IP, les règles anti-abus à la fail2ban, et tout ce qui doit s’exécuter avant que Rails n’ait démarré la requête. rate_limit est le bon outil pour une politique par controller, par utilisateur, consciente du plan, là où vous avez déjà le contexte du controller.

  • Performance. Un appel de méthode ou une invocation de proc par requête est de l’ordre de la microseconde. Ne perdez pas de temps à vous en inquiéter.

FAQ

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

Passez un nom de méthode ou un proc aux options to: et within: de rate_limit et faites-lui renvoyer des valeurs basées sur current_user.plan. Rails 8.2 les évalue à chaque requête, donc la limite reflète le plan actuel de l’utilisateur.

Can I have multiple rate_limit declarations in one controller?

Oui. Ajoutez un name: unique à chacune (par exemple name: "burst" et name: "sustained"). Sans name, Rails lève une exception quand vous en déclarez plus d’une dans le même controller.

Does Rails rate_limit work across multiple application servers?

Oui, tant que le cache store est partagé. Solid Cache, Redis et Memcached supportent tous les incréments atomiques, donc les requêtes concurrentes entre serveurs restent correctement comptées. Le :memory_store par défaut est par processus et ne fonctionne ni entre serveurs ni même entre plusieurs workers Puma.

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

rate_limit s’exécute dans le cycle de requête Rails, il a donc accès à current_user, aux méthodes du controller et au contexte de routage. Rack::Attack s’exécute à la couche Rack avant que Rails ne démarre la requête, ce qui en fait le bon outil pour les listes d’autorisation/blocage d’IP et les règles anti-abus. Utilisez les deux : Rack::Attack pour le filtrage d’IP bas niveau, rate_limit pour une politique par utilisateur, consciente du plan.

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

Utilisez l’option with: pour fournir un callback personnalisé qui définit response.headers["Retry-After"] et rend votre réponse 429. Le comportement par défaut lève ActionController::TooManyRequests, qui produit un 429 brut sans en-tête.

Limites de débit à plusieurs niveaux en production

Ce changement rend rate_limit beaucoup plus utile. Ce qui était autrefois une affaire de middleware (ou un before_action personnalisé) tient désormais en quelques lignes sur votre Api::BaseController. Tarification par paliers, limites burst plus soutenue, replis pour les anonymes, seaux par clé d’API, hooks d’observabilité, tout cela vit maintenant dans la macro.

L’implémentation est dans la PR #56128 si vous voulez lire le source. Si vous livrez une API à plusieurs niveaux pour de vrai, associez ceci à des stratégies de retry intelligentes pour que vos clients respectent les en-têtes Retry-After que vous renvoyez désormais. Et si vous cléez sur des tokens d’API plutôt que sur des utilisateurs de session, l’authentification par bearer token fonctionne bien avec ceci.