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 :
- Laisser tomber
rate_limitet recourir àRack::Attack, où la recherche par utilisateur a lieu avant que Rails n’ait chargécurrent_user. - Coder à la main un
before_actionqui compte les requêtes dans le cache et rend le 429 vous-même. - 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,requestetsessionsont 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_limitutiliseRails.cache.:memory_storeest par processus, donc deux workers Puma ne partageront pas les décomptes ;:file_storene fonctionne pas entre serveurs. En production, utilisez:solid_cache_store,:redis_cache_storeou:mem_cache_store. Avec n’importe lequel d’entre eux,cache_store.incrementest 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
limitrequêtes à 12:59:59 etlimitautres à 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.plandans 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,paramsetrequestfonctionnent ; les variables locales définies là où vous avez écritrate_limitne 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::Attacka 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_limitest 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.