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:

  1. Saltarte rate_limit y recurrir a Rack::Attack, donde la búsqueda por usuario ocurre antes de que Rails haya cargado current_user.
  2. Escribir a mano un before_action que contara las peticiones en el cache y renderizara el 429 tú mismo.
  3. 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, request y session está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_limit usa Rails.cache. :memory_store es por proceso, así que dos workers de Puma no compartirán los conteos; :file_store no funciona entre servidores. En producción, usa :solid_cache_store, :redis_cache_store o :mem_cache_store. Con cualquiera de ellos, cache_store.increment es 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 limit peticiones a las 12:59:59 y otras limit a 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.plan en la clave by: evita esto si de verdad necesitas una mejora instantánea.

  • Los procs se ejecutan en el binding del controller. current_user, params y request funcionan; las variables locales definidas donde escribiste rate_limit no. 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::Attack todaví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_limit es 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.