module Middleware
class RateLimiter
def initialize(app, redis:, limit:, window:)
@app = app
@limiter = SlidingWindowLimiter.new(redis: redis, limit: limit, window: window)
@limit = limit
end
def call(env)
request = Rack::Request.new(env)
key = client_key(request)
result = @limiter.hit(key)
return too_many_requests(result) unless result.allowed?
status, headers, body = @app.call(env)
inject_headers(headers, result)
[status, headers, body]
rescue Redis::BaseError
@app.call(env) # fail open if Redis is down
end
private
def client_key(request)
api_key = request.get_header("HTTP_X_API_KEY")
"rl:#{api_key.presence || request.ip}"
end
def too_many_requests(result)
headers = { "Content-Type" => "application/json", "Retry-After" => result.retry_after.to_s }
inject_headers(headers, result)
body = { error: "rate_limit_exceeded", retry_after: result.retry_after }.to_json
[429, headers, [body]]
end
def inject_headers(headers, result)
headers["X-RateLimit-Limit"] = @limit.to_s
headers["X-RateLimit-Remaining"] = result.remaining.to_s
headers["X-RateLimit-Reset"] = result.reset_at.to_s
end
end
end
class SlidingWindowLimiter
Result = Struct.new(:count, :limit, :window, keyword_init: true) do
def allowed?
count <= limit
end
def remaining
[limit - count, 0].max
end
def retry_after
allowed? ? 0 : window
end
def reset_at
Time.now.to_i + window
end
end
SCRIPT = <<~LUA
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local member = ARGV[3]
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
redis.call('ZADD', key, now, member)
redis.call('EXPIRE', key, window)
return redis.call('ZCARD', key)
LUA
def initialize(redis:, limit:, window:)
@redis = redis
@limit = limit
@window = window
end
def hit(key)
now = Time.now.to_i
member = "#{now}:#{SecureRandom.hex(6)}"
count = @redis.eval(SCRIPT, keys: [key], argv: [now, @window, member])
Result.new(count: count.to_i, limit: @limit, window: @window)
end
end
require "redis"
module Api
class Application < Rails::Application
config.load_defaults 7.0
REDIS = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/1"))
config.middleware.use(
Middleware::RateLimiter,
redis: REDIS,
limit: Integer(ENV.fetch("RATE_LIMIT", 100)),
window: Integer(ENV.fetch("RATE_LIMIT_WINDOW", 60))
)
end
end
This snippet shows how a per-client rate limiter is implemented as a Rack middleware backed by Redis, and how it is wired into the Rails middleware stack. Rate limiting belongs at the edge of the request lifecycle, before controllers and even before most of Rails runs, which is exactly where Rack middleware sits — so a rejected request never pays the cost of routing, authentication, or ActiveRecord.
The RateLimiter middleware implements the call(env) contract every Rack app follows. It builds a Rack::Request, derives a client identity from either an API key header or the remote IP, and asks a small window counter whether the client is over budget. When the limit is exceeded it short-circuits with a 429 Too Many Requests response and a Retry-After header instead of calling @app. On the happy path it forwards the request and injects informational X-RateLimit-* headers into the downstream response so clients can self-throttle.
The counting logic lives in SlidingWindowLimiter, which uses a Redis sorted set as a true sliding window rather than a fixed calendar bucket. Fixed windows suffer from a burst problem at boundaries: a client can send a full quota at 00:59 and another full quota at 01:00. The sorted set stores one member per request scored by timestamp, so on each call the code prunes entries older than the window with ZREMRANGEBYSCORE, counts what remains, and decides. Crucially, prune, count, add, and expire are executed atomically inside a Lua script; without atomicity two concurrent requests could both read a count under the limit and both be admitted, blowing past the quota. The script returns the current count so the middleware can compute remaining budget without a second round trip.
The Application config registers the middleware with config.middleware.use, passing the shared Redis client, the limit, and the window. Trade-offs worth noting: IP-based identity is weak behind proxies, so trusting X-Api-Key first is deliberate; the sorted set costs more memory than a plain counter but buys smoothness; and Redis being unavailable should generally fail open, which the rescue in call handles so an outage in the limiter never takes down the API.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
Share this code
Here's the card — post it anywhere.