DEV Community

Cover image for Rails Built-in Rate Limiting: A Deep Dive
Mateusz Palak
Mateusz Palak

Posted on

Rails Built-in Rate Limiting: A Deep Dive

Ruby on Rails 7.2 introduces a powerful, built-in rate limiting mechanism directly into Action Controller. This eliminates the need for third-party gems like rack-attack for many common use cases, offering a first-party, integrated solution for protecting your application from abuse.

How the Rails rate limiter works?

At its core, the Rails rate limiter allows you to define how many requests a client (by default, an IP address) can make to specific controller actions within a set time window. If the client exceeds this rate, Rails automatically returns a 429 Too Many Requests HTTP response. You can also configure a custom response if needed.

Here’s a simple example of how to use it:

class SessionsController < ApplicationController
  rate_limit to: 10, within: 3.minutes, only: :create
end
Enter fullscreen mode Exit fullscreen mode

This configuration limits each unique IP address to 10 calls to the create action (e.g., for login attempts) within a three-minute window.

Customization options

The rate_limit method is highly flexible, offering several options for tailoring its behavior to your specific needs:

- to: The maximum number of requests allowed within the defined time window.
- within: The duration of the time window for the rate limit.
- only: Specifies the controller actions to which the rate limit should be applied.
- except: Specifies the controller actions to exclude from the rate limit.
- by: A lambda that defines a custom key for rate limiting. By default, this is request.remote_ip. You can use it to limit based on, for example, a current_user.id or a domain.
- with: A lambda for custom handling when the limit is exceeded. Instead of the default 429 Too Many Requests response, you could, for instance, redirect the user.
- store: Allows you to specify a different cache store for the rate limiting data, which is crucial for scaling. By default, it uses config.action_controller.cache_store.
- name: Useful for defining multiple, separate rate limits within the same controller.

Here are some practical examples of these customization options:

# Custom grouping logic: Limit based on user ID
rate_limit to: 100, within: 1.hour, by: -> { current_user.id }

# Custom response when the limit is exceeded
rate_limit to: 5, within: 1.minute, with: -> { redirect_to login_path, alert: "Too many attempts." }

# Using a Redis cache store for distributed rate limiting
RATE_LIMIT_STORE = ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"])
rate_limit to: 10, within: 3.minutes, store: RATE_LIMIT_STORE
Under the Hood: Source Code Insights
The rate_limit method isn't magic; it's implemented efficiently using existing Rails infrastructure.
Enter fullscreen mode Exit fullscreen mode

The rate_limit method itself is a class method defined in ActionController::RateLimiting::ClassMethods.

It sets up a before_action callback. This callback triggers a private method, rate_limiting, before the specified controller actions are executed.

The request counter is incremented and stored using Rails’ ActiveSupport::Cache. The default cache store is determined by config.action_controller.cache_store, but, as shown above, you can override it per controller or action.

Cache keys are generated by combining the controller path, an optional name (if specified), and the value returned by the by block (e.g., an IP address or user ID). For example, a cache key might look like rate-limit:sessions:create:192.168.0.1.

If the count of requests exceeds the to threshold, Rails will either respond with a 429 status or execute your custom with: callback.

Here’s a simplified look at the core implementation from action_controller/metal/rate_limiting.rb: Link to source code

def rate_limit(to:, within:, by: -> { request.remote_ip }, with: -> { head :too_many_requests }, store: cache_store, name: nil, **options)
  before_action -> { rate_limiting(to: to, within: within, by: by, with: with, store: store, name: name) }, **options
end

private

def rate_limiting(to:, within:, by:, with:, store:, name:)
  # Constructing a unique cache key based on controller, optional name, and the 'by' value
  cache_key = ["rate-limit", controller_path, name, instance_exec(&by)].compact.join(":")
  # Atomically increments the counter in the cache store and sets an expiration time
  count = store.increment(cache_key, 1, expires_in: within)
  # If the count exceeds the limit, execute the 'with' callback
  if count && count > to
    instance_exec(&with)
  end
end
Enter fullscreen mode Exit fullscreen mode

How store.increment works with Redis?

When ActiveSupport::Cache::RedisCacheStore is used as the store, the store.increment(cache_key, 1, expires_in: within) method directly leverages Redis's atomic INCR command combined with an EXPIRE command.

Atomic incrementing: The INCR command atomically increases the integer value stored at the given key. If the key doesn't exist, Redis creates it and sets its initial value to 1. This guarantees thread-safe and reliable counting, even under high concurrency.

Automatic expiration: The expires_in: option tells Rails to set a Time-To-Live (TTL) for the key. Redis's EXPIRE command is used to automatically delete the key after the specified duration (e.g., 3 minutes). This effectively resets the counter for the next time window.

Atomicity and safety: The atomicity of INCR in Redis ensures that even if multiple web or worker processes hit the same key simultaneously, the counters remain consistent and reliable.

This mechanism ensures that each "limit key" (e.g., an IP address or user ID) gets its own independent counter in Redis, which increments with each request, is safely reset after the time window, and is automatically cleaned up for memory efficiency. If more requests arrive after a key has expired, the counter starts fresh at one.

Client IP resolution: request.remote_ip vs. request.ip

By default, the Rails rate limiter uses request.remote_ip to identify the client. This is crucial for accurate rate limiting, especially when your application is behind a proxy or CDN like Cloudflare.

Here's a breakdown of the two methods for obtaining a client's IP in Rails:
- request.ip: This method originates from Rack. It retrieves the IP address without advanced logic, often returning the address of the last proxy (e.g., Cloudflare, load balancer) rather than the actual client's IP. If your server is behind a CDN or proxy, this method is more likely to give you the intermediary's IP.
- request.remote_ip: This is a Rails-specific method that leverages the ActionDispatch::RemoteIp middleware. It intelligently inspects and interprets HTTP headers like X-Forwarded-For and Client-IP, considering a list of trusted proxies (which you can configure). It aims to determine the "most probable" client IP address by bypassing known proxy/CDN addresses. It also incorporates logic to detect IP spoofing, offering a more robust and accurate client IP.

Cloudflare and request.remote_ip

When using Cloudflare, your server typically sees Cloudflare's IP addresses, not the end-user's actual IP. Cloudflare sends the original client's IP in the CF-Connecting-IP header and adds its own IPs to X-Forwarded-For.

To ensure request.remote_ip accurately identifies the client behind Cloudflare, you must configure trusted proxies in your Rails application. In config/environments/production.rb, you would add Cloudflare's CIDR IP ranges:

config.action_dispatch.trusted_proxies = [
  # Cloudflare IP CIDR list – see: https://www.cloudflare.com/ips/
  IPAddr.new('173.245.48.0/20'),
  # ... and so on for all Cloudflare ranges
]
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can use the cloudflare-rails gem, which automatically updates the trusted proxy list and ensures both request.ip and request.remote_ip behave correctly.

For production environments behind Cloudflare or any other reverse proxy, configuring trusted_proxies and relying on request.remote_ip is essential for the rate limiter to function as intended.

Advantages of built-in rate limiting

- First-party: No need for additional gems or external dependencies for standard rate limiting, simplifying your Gemfile and reducing potential conflicts.
- Declarative: Offers a simple and expressive API directly within your controllers, making it easy to understand and implement.
- Flexible: Provides extensive customization options with by: and with: for custom grouping logic and response handling.
- Cache-backed: Leverages Rails' ActiveSupport::Cache, enabling scalability with support for distributed cache stores like Redis, Memcached or Solid Cache.
- Multiple limits: You can apply different rate limits to different actions or define multiple, distinct limits within the same controller using the name: option.

Limitations and considerations

While powerful, the built-in rate limiter has a few considerations:
- Dependency on Rails cache: The rate limiter is ineffective if caching is not enabled or properly configured. For example, in development mode, you might need to run rails dev:cache or ensure a cache directory (like tmp/cache) exists for file_store caching to function. A common pitfall is the rate limiter silently failing if the cache store isn't operational.
- Per-process cache (default): With certain cache stores (like MemoryStore), rate limits are not shared between server processes. For production deployments, you must use a shared, centralized cache store to ensure consistent rate limiting across all your application instances.
- Testing gotchas: If your test suite uses a persistent or memory-backed cache store, rate limits might not reset between tests, potentially leading to flaky or unexpected test failures. Be mindful of cache clearing in your test setup.
- Non-distributed by default: For global rate limits that span your entire infrastructure (e.g., limiting requests across all microservices), you'll need to explicitly configure a common, shared backing cache.

Anecdote: The mystery of the non-working rate limiter

During my initial implementation, I encountered a frustrating debugging session that perfectly illustrates one of the system’s key dependencies. I tested it locally by hammering the endpoint with dozens of requests, expecting to see 429 errors after the second request. But nothing happened, every request succeeded, and I couldn’t trigger the rate limit no matter how many requests I sent.
After checking and rechecking my code, examining the source implementation, and even adding logging to verify the rate limiting code was executing, I finally discovered the issue: my local development environment didn’t have caching enabled.
Rails needs a functional cache store to persist the request counters. Without the tmp/caching-dev.txt file, the cache store was effectively a no-op, so every increment operation was silently discarded. The rate limiter was running, but it couldn’t count anything! A simple rails dev:cache command immediately fixed the issue, and suddenly my rate limits worked perfectly. This experience taught me to always verify cache store functionality before debugging rate limiting logic – it’s the foundation everything else depends on.

Conclusion

Rails built-in rate limiting feature provides an easy, first-class, and expressive way to protect your application from various forms of abuse directly within your controllers. It's an ideal solution for most typical scenarios, offering high configurability and leveraging Rails' established caching infrastructure for scalability.

For more complex or unusual policies, such as global quotas across multiple services, sophisticated "burst" algorithms, or advanced whitelisting, more specialized third-party libraries or external solutions (like those provided by CDNs or reverse proxies) might still be required. However, for most application-level rate limiting needs, the new Rails feature is a significant and welcome improvement.

Have you tried the built-in Rails rate limiter in your applications? How does it compare to other solutions you've used? I'd love to hear your experiences!

Top comments (2)

Collapse
 
vikas profile image
Vikas Kumar

will it work with solid cache?

Collapse
 
palak profile image
Mateusz Palak

Yes, absolutely - it works with Solid Cache. In the article, I used Redis as an example because that’s what we use at work, but in my personal project I use Solid Cache.