Límites de tasa de API por niveles en Rails 8.2 con rate_limit dinámico
Nota: esta funcionalidad llega en Rails 8.2. Está mergeada en main pero aún no publicada. Puedes ver el código en GitHub o probarlo apuntando tu Gemfile a la rama main.
Desde Rails 8.0 tienes una macro rate_limit en tus controllers. El problema era que to: y within: tenían que ser números fijos, lo que hacía incómoda la tarificación por niveles. Rails 8.2 permite que ambas opciones acepten procs o nombres de método, así que por fin puedes hacer «Free 100/min, Pro 1k/hora, Enterprise ilimitado» sin recurrir a Rack::Attack.
Por qué rate_limit era estático antes de Rails 8.2
Antes de la 8.2, rate_limit solo podía ser estático:
class Api::BaseController < ApplicationController
rate_limit to: 100, within: 1.minute
end
Cada usuario recibía el mismo límite. Si antes querías niveles por plan, tus opciones eran:
- Saltarte
rate_limity recurrir aRack::Attack, donde la búsqueda por usuario ocurre antes de que Rails haya cargadocurrent_user. - Escribir a mano un
before_actionque contara las peticiones en el cache y renderizara el 429 tú mismo. - Definir un controller por nivel y enrutar las subclases según el plan.
Las tres duplicaban lógica que Rails ya tenía. La pieza que faltaba era la capacidad de calcular el límite a partir del contexto de la petición.
Cómo funciona el rate_limit dinámico en Rails 8.2
En Rails 8.2 (PR #56128), to: y within: aceptan procs y nombres de método además de valores estáticos, igual que by: ya lo hacía. Las reglas son simples:
- Un símbolo se despacha como un método sin argumentos en el controller (
send(:max_requests)). - Un proc o lambda se ejecuta como si fuera un método del controller, así que
current_user,params,requestysessionestán todos en el ámbito. Los procs no toman argumentos.
Eso es todo. El resto del artículo es lo que puedes construir con ello.
Patrón 1: niveles Free / Pro / Enterprise
Si estás construyendo un SaaS multitenant, este es el patrón que estabas esperando. Así se ve:
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
En cada petición, Rails llama a esos dos métodos en el controller, así que el límite refleja el plan en el que el usuario está ahora mismo. El proc by: indexa el contador por el ID del usuario, dando a cada uno su propio cubo.
Probar los límites por niveles
Así se prueban las configuraciones de límite de tasa. Hay dos cosas que manejar: limpiar el cache entre ejecuciones, y viajar en el tiempo a través de la ventana.
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
Para ejecuciones de tests en paralelo, dale a cada worker su propio cache, o cambia a :null_store y verifica que la macro esté configurada en lugar de verificar el conteo.
Patrón 2: límites burst + sostenido
1.000 peticiones por hora suena generoso hasta que un usuario las dispara todas en tres segundos. Superpón un límite de burst sobre el sostenido:
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
En cuanto tienes más de un rate_limit en un controller, name: se vuelve obligatorio. Es lo que le dice a Rails cuál contador es cuál en el cache.
Patrón 3: anónimo vs. autenticado
Si tu endpoint es público, no hay current_user por el que indexar. Baja a la IP y un techo más bajo. El proc by: maneja ambos casos de una vez, y el mismo truco funciona para las claves de 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 detalle a tener en cuenta: si estás detrás de Cloudflare u otro proxy, request.remote_ip será la IP del proxy a menos que config.action_dispatch.trusted_proxies esté configurado. Sáltatelo y cada petición anónima hashea al mismo cubo, lo que deja tu límite de tasa bastante inútil.
Patrón 4: respuestas 429 amigables (con instrumentación)
Por defecto, alcanzar el límite lanza ActionController::TooManyRequests, que Action Dispatch devuelve como un 429 plano. Eso está bien para HTML, pero los clientes de API normalmente quieren JSON y una cabecera Retry-After. Ya que estás personalizando la respuesta, el mismo bloque with: es un buen lugar para disparar un evento ActiveSupport::Notifications para tu APM, ya que la macro no emite ninguno por sí sola.
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
Suscríbete a rate_limit.exceeded en un initializer y tendrás un feed para Datadog, Honeycomb, o donde sea que vivan tus paneles de tráfico. Los clientes (o tus jobs en segundo plano usando estrategias de retry inteligentes) pueden leer Retry-After y retroceder en consecuencia.
Advertencias y casos límite de rate_limit en Rails
Algunas cosas que la macro no te dice de entrada:
-
Elección del cache store.
rate_limitusaRails.cache.:memory_storees por proceso, así que dos workers de Puma no compartirán los conteos;:file_storeno funciona entre servidores. En producción, usa:solid_cache_store,:redis_cache_storeo:mem_cache_store. Con cualquiera de ellos,cache_store.incrementes atómico, así que las peticiones concurrentes entre varios servidores de aplicación se mantienen correctas sin coordinación adicional. -
Ventana fija, no deslizante. Los contadores se reinician en los límites de la ventana. Un usuario puede disparar
limitpeticiones a las 12:59:59 y otraslimita las 13:00:00. Si eso te molesta, superpón un límite de burst más ajustado (Patrón 2). -
Las mejoras de plan tienen efecto en la siguiente ventana. El límite se recalcula en cada petición, pero el conteo de la ventana en curso se arrastra. Un usuario free en 99/100 que sube a Pro seguirá recibiendo un 429 en su siguiente petición ese minuto. Incluir
current_user.planen la claveby:evita esto si de verdad necesitas una mejora instantánea. -
Los procs se ejecutan en el binding del controller.
current_user,paramsyrequestfuncionan; las variables locales definidas donde escribisterate_limitno. Cíñete a los métodos del controller y al estado de instancia. -
Los métodos símbolo no toman argumentos. Se despachan vía
send(name), así que solo devuelve el valor. No intentes aceptar la petición como parámetro. -
Límites compartidos entre controllers. Pasa
scope:para que varios controllers compartan un cubo. Útil para configuraciones de «todas las escrituras cuentan contra un único límite». -
Rack::Attacktodavía tiene su lugar. Resérvalo para listas de permitidos/bloqueados de IP, reglas antiabuso al estilo fail2ban, y cualquier cosa que necesite ejecutarse antes de que Rails haya arrancado la petición.rate_limites la herramienta adecuada para una política por controller, por usuario, consciente del plan, donde ya tienes contexto del controller. -
Rendimiento. Una llamada a método o una invocación de proc por petición está en el rango de los microsegundos. No pierdas tiempo preocupándote por ello.
FAQ
How do I rate-limit Rails API requests by user plan?
Pasa un nombre de método o un proc a las opciones to: y within: de rate_limit y haz que devuelva valores basados en current_user.plan. Rails 8.2 los evalúa en cada petición, así que el límite refleja el plan actual del usuario.
Can I have multiple rate_limit declarations in one controller?
Sí. Añade un name: único a cada una (por ejemplo name: "burst" y name: "sustained"). Sin name, Rails lanza una excepción cuando declaras más de una en el mismo controller.
Does Rails rate_limit work across multiple application servers?
Sí, siempre que el cache store sea compartido. Solid Cache, Redis y Memcached soportan incrementos atómicos, así que las peticiones concurrentes entre servidores se cuentan correctamente. El :memory_store por defecto es por proceso y no funciona entre servidores ni siquiera entre varios workers de Puma.
What’s the difference between Rails rate_limit and Rack::Attack?
rate_limit se ejecuta dentro del ciclo de petición de Rails, así que tiene acceso a current_user, a los métodos del controller y al contexto de enrutamiento. Rack::Attack se ejecuta en la capa Rack antes de que Rails arranque la petición, lo que lo convierte en la herramienta adecuada para listas de permitidos/bloqueados de IP y reglas antiabuso. Usa ambos: Rack::Attack para el filtrado de IP de bajo nivel, rate_limit para una política por usuario, consciente del plan.
How do I send a Retry-After header from rate_limit?
Usa la opción with: para proporcionar un callback personalizado que establezca response.headers["Retry-After"] y renderice tu respuesta 429. El comportamiento por defecto lanza ActionController::TooManyRequests, que produce un 429 plano sin cabecera.
Límites de tasa por niveles en producción
Este cambio hace que rate_limit sea mucho más útil. Lo que antes era asunto de middleware (o un before_action personalizado) ahora cabe en unas pocas líneas en tu Api::BaseController. Tarificación por niveles, límites de burst más sostenido, fallbacks anónimos, cubos por clave de API, hooks de observabilidad, todo eso ahora vive en la macro.
La implementación está en la PR #56128 si quieres leer el código. Si vas a lanzar de verdad una API por niveles, combina esto con estrategias de retry inteligentes para que tus clientes respeten las cabeceras Retry-After que ahora devuelves. Y si indexas por tokens de API en lugar de usuarios de sesión, la autenticación con bearer token funciona bien con esto.