This commit introduces a dual-mode caching and rate limiting system that can use either Redis (for distributed deployments) or ETS/Cachex (for single-node setups). Key changes: - Add Cache abstraction layer that automatically switches between Redis and Cachex - Implement RedisCache module with full distributed cache functionality - Implement RedisRateLimiter with sliding window algorithm for accurate rate limiting - Add RateLimiterWrapper to provide unified API for both implementations - Update application startup to conditionally use Redis when REDIS_URL is set - Migrate all cache operations to use the new Cache abstraction - Support graceful fallback to ETS-based solutions when Redis is unavailable The system automatically detects Redis availability via REDIS_URL environment variable and switches between implementations without code changes. This enables proper distributed caching and rate limiting in Kubernetes deployments while maintaining backward compatibility for development environments. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
159 lines
3.8 KiB
Elixir
159 lines
3.8 KiB
Elixir
defmodule Aprsme.RedisRateLimiter do
|
|
@moduledoc """
|
|
Redis-backed distributed rate limiter using sliding window algorithm.
|
|
"""
|
|
|
|
require Logger
|
|
|
|
@redis_pool_size 5
|
|
|
|
def child_spec(_opts) do
|
|
children = [
|
|
{Redix,
|
|
name: __MODULE__, host: redis_host(), port: redis_port(), password: redis_password(), pool_size: @redis_pool_size}
|
|
]
|
|
|
|
%{
|
|
id: __MODULE__,
|
|
type: :supervisor,
|
|
start: {Supervisor, :start_link, [children, [strategy: :one_for_one, name: :"#{__MODULE__}_supervisor"]]}
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Check rate limit using sliding window algorithm.
|
|
|
|
## Parameters
|
|
- bucket: The rate limit bucket (e.g., "user:123" or "ip:192.168.1.1")
|
|
- limit: Maximum number of requests allowed
|
|
- window_ms: Time window in milliseconds
|
|
|
|
## Returns
|
|
- {:allow, count} if under limit
|
|
- {:deny, limit} if over limit
|
|
"""
|
|
def check_rate(bucket, limit, window_ms) do
|
|
now = System.system_time(:millisecond)
|
|
window_start = now - window_ms
|
|
key = make_key(bucket)
|
|
|
|
# Lua script for atomic sliding window rate limiting
|
|
lua_script = """
|
|
local key = KEYS[1]
|
|
local now = tonumber(ARGV[1])
|
|
local window_start = tonumber(ARGV[2])
|
|
local limit = tonumber(ARGV[3])
|
|
|
|
-- Remove old entries
|
|
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
|
|
|
|
-- Count current entries
|
|
local current = redis.call('ZCARD', key)
|
|
|
|
if current < limit then
|
|
-- Add new entry
|
|
redis.call('ZADD', key, now, now)
|
|
-- Set expiry
|
|
redis.call('EXPIRE', key, math.ceil(ARGV[4] / 1000))
|
|
return {1, current + 1}
|
|
else
|
|
return {0, limit}
|
|
end
|
|
"""
|
|
|
|
case Redix.command(__MODULE__, ["EVAL", lua_script, 1, key, now, window_start, limit, window_ms]) do
|
|
{:ok, [1, count]} ->
|
|
{:allow, count}
|
|
|
|
{:ok, [0, limit]} ->
|
|
{:deny, limit}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Redis rate limit error for #{bucket}: #{inspect(reason)}")
|
|
# Fail open - allow request if Redis is down
|
|
{:allow, 1}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Reset rate limit for a bucket
|
|
"""
|
|
def reset(bucket) do
|
|
key = make_key(bucket)
|
|
|
|
case Redix.command(__MODULE__, ["DEL", key]) do
|
|
{:ok, _} ->
|
|
:ok
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Redis rate limit reset error for #{bucket}: #{inspect(reason)}")
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get current count for a bucket
|
|
"""
|
|
def count(bucket, window_ms) do
|
|
now = System.system_time(:millisecond)
|
|
window_start = now - window_ms
|
|
key = make_key(bucket)
|
|
|
|
# Clean up old entries and count
|
|
case Redix.pipeline(__MODULE__, [
|
|
["ZREMRANGEBYSCORE", key, 0, window_start],
|
|
["ZCARD", key]
|
|
]) do
|
|
{:ok, [_, count]} ->
|
|
{:ok, count}
|
|
|
|
{:error, reason} ->
|
|
Logger.error("Redis rate limit count error for #{bucket}: #{inspect(reason)}")
|
|
{:error, reason}
|
|
end
|
|
end
|
|
|
|
@doc """
|
|
Get remaining requests for a bucket
|
|
"""
|
|
def remaining(bucket, limit, window_ms) do
|
|
case count(bucket, window_ms) do
|
|
{:ok, current} -> {:ok, max(0, limit - current)}
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
# Private functions
|
|
|
|
defp make_key(bucket) do
|
|
"aprsme:rate_limit:#{bucket}"
|
|
end
|
|
|
|
defp redis_host do
|
|
redis_url = System.get_env("REDIS_URL", "redis://localhost:6379")
|
|
uri = URI.parse(redis_url)
|
|
uri.host || "localhost"
|
|
end
|
|
|
|
defp redis_port do
|
|
redis_url = System.get_env("REDIS_URL", "redis://localhost:6379")
|
|
uri = URI.parse(redis_url)
|
|
uri.port || 6379
|
|
end
|
|
|
|
defp redis_password do
|
|
redis_url = System.get_env("REDIS_URL", "redis://localhost:6379")
|
|
uri = URI.parse(redis_url)
|
|
|
|
case uri.userinfo do
|
|
nil ->
|
|
nil
|
|
|
|
userinfo ->
|
|
case String.split(userinfo, ":") do
|
|
[_, password] -> password
|
|
_ -> nil
|
|
end
|
|
end
|
|
end
|
|
end
|