require "securerandom"
require "digest"
class SlidingWindowLimiter
Result = Struct.new(:allowed, :count, :remaining)
def initialize(redis:, limit:, window_ms:)
@redis = redis
@limit = limit
@window_ms = window_ms
@script = File.read(File.expand_path("rate_limit.lua", __dir__))
@sha = Digest::SHA1.hexdigest(@script)
end
def allow?(client_key)
now_ms = redis_time_ms
member = "#{now_ms}-#{SecureRandom.hex(4)}"
allowed, count = eval_script(client_key, now_ms, member)
Result.new(allowed == 1, count, [@limit - count, 0].max)
end
private
def eval_script(key, now_ms, member)
args = [now_ms, @window_ms, @limit, member]
@redis.evalsha(@sha, keys: ["ratelimit:#{key}"], argv: args)
rescue Redis::CommandError => e
raise unless e.message.include?("NOSCRIPT")
@redis.eval(@script, keys: ["ratelimit:#{key}"], argv: args)
end
def redis_time_ms
secs, micros = @redis.time
(secs * 1000) + (micros / 1000)
end
end
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
-- Drop everything older than the trailing window.
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, member)
redis.call('PEXPIRE', key, window)
return { 1, count + 1 }
end
redis.call('PEXPIRE', key, window)
return { 0, count }
require "json"
class RackThrottle
def initialize(app, limiter:, logger: nil)
@app = app
@limiter = limiter
@logger = logger
end
def call(env)
key = client_key(env)
result = @limiter.allow?(key)
unless result.allowed
return too_many_requests(result)
end
status, headers, body = @app.call(env)
headers["X-RateLimit-Limit"] = @limiter.instance_variable_get(:@limit).to_s
headers["X-RateLimit-Remaining"] = result.remaining.to_s
[status, headers, body]
rescue => e
@logger&.error("rate limiter failed, failing open: #{e.class}: #{e.message}")
@app.call(env)
end
private
def client_key(env)
token = env["HTTP_AUTHORIZATION"].to_s[/Bearer\s+(.+)/, 1]
token || Rack::Request.new(env).ip
end
def too_many_requests(result)
body = JSON.dump(error: "rate_limit_exceeded", retry_after: 1)
headers = {
"Content-Type" => "application/json",
"Retry-After" => "1",
"X-RateLimit-Remaining" => "0"
}
[429, headers, [body]]
end
end
require "redis"
require "logger"
require_relative "sliding_window_limiter"
require_relative "rack_throttle"
require_relative "app"
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
limiter = SlidingWindowLimiter.new(
redis: redis,
limit: 100,
window_ms: 60_000
)
use RackThrottle, limiter: limiter, logger: Logger.new($stdout)
run API::Application.new
Rate limiting protects an API from bursts and abuse by capping how many requests a client may make in a rolling time span. This snippet implements a sliding-window limiter as Rack middleware backed by Redis, using a sorted set per client so the window slides continuously rather than resetting on fixed boundaries. Fixed-window counters are simpler but suffer from the boundary problem: a client can send a full quota at the end of one window and another full quota at the start of the next, briefly doubling the intended rate. A sliding window avoids that by scoring every request with its timestamp and only counting those inside the trailing window.
The SlidingWindowLimiter file holds the core algorithm. Each client gets a Redis sorted set keyed by identity; request timestamps (in milliseconds) are the members and the scores. On every call, #allow? removes entries older than now - window with ZREMRANGEBYSCORE, counts the survivors with ZCARD, and, if under the limit, adds the new timestamp and sets a TTL so idle keys expire. Running these steps individually would be racy under concurrency, so they are executed atomically inside a Lua script via EVALSHA. The script returns both the current count and the allowance decision, which lets the middleware emit accurate X-RateLimit-Remaining headers without a second round trip.
The Lua body lives in rate_limit.lua. Because Redis runs the whole script as a single atomic operation, no other client can interleave between the prune, the count, and the insert. KEYS[1] is the client bucket; the ARGV carry the current time, window size, limit, and a unique member value (timestamp plus a random suffix) so two requests landing in the same millisecond do not collide in the sorted set. The script returns {allowed, count}, and it only writes the new member when the request is actually permitted, keeping rejected requests from inflating the window.
The RackThrottle middleware wires this into a Rack app. #call derives a client key — an API token when present, otherwise the remote IP — and consults the limiter. When the request is allowed it forwards to the next app in the stack and decorates the response with X-RateLimit-Limit and X-RateLimit-Remaining. When the limit is exceeded it short-circuits with a 429 Too Many Requests JSON body and a Retry-After header, never touching the downstream app. Fail-open behavior is deliberate: if Redis raises, #call logs and lets the request through, since a limiter outage should not become a full outage.
The config.ru file shows the assembly, inserting the middleware ahead of the application with a shared Redis pool and per-route policy. Trade-offs worth noting: sorted sets cost memory proportional to requests-in-window per client, so very high limits should prefer approximate counters; and clock skew across app servers is avoided by using Redis TIME as the authority rather than each host's clock.
Share this code
Here's the card — post it anywhere.