DEV Community

Yaroslav Litvinov
Yaroslav Litvinov

Posted on

Built-in Rate Limiting in Rails 8

Rails 8 comes with simple built-in RateLimiting feature

Basic usage

You can apply a basic rate limit in your controller with the rate_limit directive:

class GreetingsController < ApplicationController rate_limit to: 5, within: 1.minute, by: -> { request.ip } def greet render json: {message: "Hello, #{name}!"}, status: :ok end def goodbye render json: {message: "Bye, #{name}!"}, status: :ok end private def name params.require(:name) end end 
Enter fullscreen mode Exit fullscreen mode

Multiple named rate limits

use :name argument when using multiple rate limits

class GreetingsController < ApplicationController rate_limit to: 2, within: 1.second, by: -> { request.ip }, name: "short-term" rate_limit to: 1000, within: 10.minutes, by: -> { request.ip }, name: "long-term" def greet render json: {message: "Hello, #{name}!"}, status: :ok end def goodbye render json: {message: "Bye, #{name}!"}, status: :ok end private def name params.require(:name) end end 
Enter fullscreen mode Exit fullscreen mode

Rate limits per action

assign limit to a specific action by using :only argument:

class GreetingsController < ApplicationController rate_limit to: 5, within: 1.minute, by: -> { request.ip }, name: "greet-limit", only: :greet rate_limit to: 6, within: 70.seconds, by: -> { request.ip }, name: "bye-limit", only: :goodbye def greet render json: {message: "Hello, #{name}!"}, status: :ok end def goodbye render json: {message: "Bye, #{name}!"}, status: :ok end private def name params.require(:name) end end 
Enter fullscreen mode Exit fullscreen mode

Adding Default Rate Limits

It's easy to extract the default rate limits into a module and include it in your controllers as default behavior.
We can use with option to define a custom error handler.

module DefaultRateLimits extend ActiveSupport::Concern included do rate_limit to: 45, within: 1.minute, by: -> { request.ip }, name: "long-term", with: -> { too_many_requests } rate_limit to: 3, within: 2.seconds, by: -> { request.ip }, name: "short-term", with: -> { too_many_requests } def too_many_requests(msg = nil) err = "Rate limit exceeded." if msg err += " #{msg}." end render json: {error: err}, status: :too_many_requests end end end 
Enter fullscreen mode Exit fullscreen mode

include DefaultRateLimits in your controller:

class GreetingsController < ApplicationController include DefaultRateLimits rate_limit to: 3, within: 10.minutes, by: -> { request.params["name"] }, name: "greet-limit", only: :greet, with: -> { too_many_requests("Try again in 10 minutes") } rate_limit to: 5, within: 30.minutes, by: -> { request.params["name"] }, name: "goodbye-limit", only: :goodbye, with: -> { too_many_requests("Try again in 30 minutes") } def greet render json: {message: "Hello, #{name}!"}, status: :ok end def goodbye render json: {message: "Bye, #{name}!"}, status: :ok end private def name params.require(:name) end end 
Enter fullscreen mode Exit fullscreen mode

These defaults apply to all actions unless overridden by more specific rate limits.

In this example the 'greet' action will have 3 rate limits applied concurrently:

  • short-term: ip-based
  • long-term: ip-based
  • greet-limit: payload-based (params[:name])

The 429 response will look like:

{ "error": "Rate limit exceeded. Try again in 30 minutes." } 
Enter fullscreen mode Exit fullscreen mode

Caching Strategies

Under the hood rate_limit uses Rails cache store (ActiveSupport::Cache)
We can override it by passing a custom store, e.g:

class APIController < ApplicationController RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"]) rate_limit to: 10, within: 3.minutes, store: RATE_LIMIT_STORE end 
Enter fullscreen mode Exit fullscreen mode

In production environments, it's recommended to use distributed caching systems such as Redis or Memcached for better performance and scalability. For development purposes, you can use ActiveSupport::Cache::MemoryStore, which is simpler and runs in memory.

# config/application.rb or config/environments config.cache_store = :memory_store, { size: 64.megabytes } 
Enter fullscreen mode Exit fullscreen mode

Testing

Internally rate limit cache key implemented as:

def rate_limiting(to:, within:, by:, with:, store:, name:) cache_key = ["rate-limit", controller_path, name, instance_exec(&by)].join(":") count = store.increment(cache_key, 1, expires_in: within) if count && count > to ActiveSupport::Notifications.instrument("rate_limit.action_controller", request: request) do instance_exec(&with) end end end 
Enter fullscreen mode Exit fullscreen mode

see rate_limiting.rb

When testing your API you can set the cache counter manually. This allows you to simulate throttled requests without waiting for the limit to be naturally hit. In your request/controller spec:

before do cache_key = ["rate-limit", "greetings", "greet-limit", "Rachel"].join(":") Rails.cache.increment(cache_key, 10, expires_in: 1.minutes) end it "returns 429" do # Your test code here end 
Enter fullscreen mode Exit fullscreen mode

Don't forget to reset the cache after each test:

after do cache_key = ["rate-limit", "greetings", "greet-limit", "Rachel"].join(":") Rails.cache.delete(cache_key) end 
Enter fullscreen mode Exit fullscreen mode

or in rails_helper:

RSpec.configure do |config| config.after(:each) do |example| Rails.cache.clear end end 
Enter fullscreen mode Exit fullscreen mode

Summary

The RateLimiting module in Rails 8 offers a simple yet effective way to throttle requests, suitable for many applications from development through production.
It's easy to configure, helps prevent abuse, and integrates seamlessly with Rails controllers.
While ideal for most standard use cases, it may fall short in scenarios requiring dynamic rules, geo-based blocking, or distributed request coordination. For advanced rate limiting, consider libraries like rack-attack.

links:

Top comments (0)