aprs.me/lib/aprsme/redis_rate_limiter.ex
Graham McIntire d5781f2581
Fix comprehensive crash prevention and add missing RF path station markers
This commit addresses two major areas:

1. Crash Prevention & Error Handling:
   - Added error handling to all database queries to prevent crashes
   - Implemented circuit breaker pattern for APRS-IS connection
   - Added timeouts to all Redis operations (5 second timeout)
   - Fixed unsafe pattern matches that could cause MatchError crashes
   - Added try/rescue blocks around background tasks
   - Improved socket operation error handling with proper cleanup
   - Enhanced packet cleanup worker with comprehensive error handling

2. RF Path Station Markers:
   - Fixed issue where digipeater/iGate icons weren't shown when searching for callsigns
   - Now loads and displays position markers for all stations in the RF path
   - Extracts RF path stations from tracked callsign packets
   - Fetches latest position for each digipeater/iGate
   - Properly displays complete RF path visualization with station positions

Technical details:
- Added Process.alive? checks before sending messages
- Fixed resource cleanup in terminate functions
- Added proper error handling to File.read operations
- Implemented safe wrapper for ShutdownHandler health checks
- All changes ensure the app continues running even when errors occur

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-28 11:56:00 -05:00

163 lines
4 KiB
Elixir

defmodule Aprsme.RedisRateLimiter do
@moduledoc """
Redis-backed distributed rate limiter using sliding window algorithm.
"""
require Logger
# 5 seconds timeout for Redis operations
@redis_timeout 5000
def child_spec(_opts) do
children = [
{Redix, name: __MODULE__, host: redis_host(), port: redis_port(), password: redis_password()}
]
%{
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],
timeout: @redis_timeout
) 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], timeout: @redis_timeout) 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]
], timeout: @redis_timeout) 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