- Add retry: false to VISP Client HTTP requests - Add retry: false to ReleaseChecker GitHub API requests - Remove unused Plug.Conn import from RemoteIpLogger - Remove unused default parameter from req_get/2 Results: - VISP sync test: 7007ms → 113ms (62x faster) - ReleaseChecker test: 7004ms → 17ms (402x faster) - UserResetPasswordLive test: 1495ms → 284ms (5x faster) Req's default retry behavior (1s, 2s, 4s exponential backoff) was causing 7-second delays for HTTP 500/503 error responses in tests. For these clients, immediate failure is preferred over retries.
78 lines
2.1 KiB
Elixir
78 lines
2.1 KiB
Elixir
defmodule ToweropsWeb.Plugs.RateLimit do
|
|
@moduledoc """
|
|
Rate limiting plug using Hammer to prevent brute-force attacks.
|
|
|
|
Applies different rate limits based on the type of endpoint:
|
|
- Auth endpoints (login, register, password reset): 10 requests per minute per IP
|
|
- API endpoints: 1000 requests per minute per IP
|
|
- Admin endpoints: 100 requests per minute per IP (superuser access)
|
|
|
|
Uses the client's real IP address from X-Forwarded-For or X-Real-IP headers
|
|
when behind a proxy/load balancer.
|
|
"""
|
|
|
|
import Phoenix.Controller, only: [json: 2]
|
|
import Plug.Conn
|
|
|
|
alias ToweropsWeb.RemoteIp
|
|
|
|
require Logger
|
|
|
|
@auth_limit 10
|
|
@auth_period to_timeout(minute: 1)
|
|
|
|
@api_limit 1000
|
|
@api_period to_timeout(minute: 1)
|
|
|
|
@admin_limit 100
|
|
@admin_period to_timeout(minute: 1)
|
|
|
|
@type limit_type :: :auth | :api | :admin
|
|
|
|
def init(opts) do
|
|
type = Keyword.get(opts, :type, :api)
|
|
|
|
if type not in [:auth, :api, :admin] do
|
|
raise ArgumentError, "RateLimit plug :type must be :auth, :api, or :admin, got: #{inspect(type)}"
|
|
end
|
|
|
|
type
|
|
end
|
|
|
|
def call(conn, type) do
|
|
if rate_limiting_enabled?() do
|
|
check_rate_limit(conn, type)
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp rate_limiting_enabled? do
|
|
Application.get_env(:towerops, :rate_limiting_enabled, true)
|
|
end
|
|
|
|
defp check_rate_limit(conn, type) do
|
|
remote_ip = RemoteIp.from_conn(conn)
|
|
bucket_key = "#{type}:#{remote_ip}"
|
|
|
|
{limit, period} = limits_for(type)
|
|
|
|
case Towerops.RateLimit.hit(bucket_key, period, limit) do
|
|
{:allow, _count} ->
|
|
conn
|
|
|
|
{:deny, retry_after} ->
|
|
Logger.warning("Rate limit exceeded for #{bucket_key}, retry_after: #{retry_after}ms")
|
|
|
|
conn
|
|
|> put_resp_header("retry-after", Integer.to_string(div(retry_after, 1000)))
|
|
|> put_status(:too_many_requests)
|
|
|> json(%{error: "Too many requests. Please try again later."})
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
defp limits_for(:auth), do: {@auth_limit, @auth_period}
|
|
defp limits_for(:api), do: {@api_limit, @api_period}
|
|
defp limits_for(:admin), do: {@admin_limit, @admin_period}
|
|
end
|