cover/Elixir.DaProductAppWeb.Plugs.RateLimiter.html

1 defmodule DaProductAppWeb.Plugs.RateLimiter do
2 @moduledoc """
3 Rate limiting plug for partner APIs.
4
5 Uses Cachex for in-memory rate limiting based on partner ID.
6 Rate limits are per-partner and configurable in the partner record.
7 """
8
9 import Plug.Conn
10 alias DaProductApp.Partners
11
12 @cache_name :rate_limiter
13 @default_window_seconds 60
14
15
:-(
def init(opts), do: opts
16
17 def call(%{assigns: %{current_partner: partner}} = conn, _opts) do
18
:-(
case check_rate_limit(partner) do
19 :ok ->
20
:-(
conn
21 {:error, :rate_limit_exceeded, retry_after} ->
22
:-(
rate_limit_exceeded(conn, retry_after)
23 end
24 end
25
26 # Skip rate limiting if no partner is assigned (shouldn't happen after auth)
27
:-(
def call(conn, _opts), do: conn
28
29 # Check rate limit for a partner
30 defp check_rate_limit(partner) do
31
:-(
limit = Partners.get_rate_limit(partner)
32
:-(
window_key = "partner:#{partner.id}:#{current_window()}"
33
34
:-(
case Cachex.get(@cache_name, window_key) do
35 {:ok, nil} ->
36 # First request in this window
37
:-(
Cachex.put(@cache_name, window_key, 1, ttl: :timer.seconds(@default_window_seconds))
38 :ok
39
40 {:ok, count} when count < limit ->
41 # Within rate limit
42
:-(
Cachex.incr(@cache_name, window_key)
43 :ok
44
45 {:ok, _count} ->
46 # Rate limit exceeded
47
:-(
retry_after = @default_window_seconds - rem(System.system_time(:second), @default_window_seconds)
48
:-(
{:error, :rate_limit_exceeded, retry_after}
49
50 {:error, _} ->
51 # Cache error, allow request but log
52 require Logger
53
:-(
Logger.warning("Rate limiter cache error for partner #{partner.id}")
54 :ok
55 end
56 end
57
58 # Get current time window (minute-based)
59 defp current_window do
60
:-(
div(System.system_time(:second), @default_window_seconds)
61 end
62
63 # Send rate limit exceeded response
64 defp rate_limit_exceeded(conn, retry_after) do
65 conn
66 |> put_status(:too_many_requests)
67
:-(
|> put_resp_header("retry-after", to_string(retry_after))
68
:-(
|> put_resp_header("x-ratelimit-reset", to_string(System.system_time(:second) + retry_after))
69 |> put_resp_content_type("application/json")
70 |> send_resp(429, Jason.encode!(%{
71 error: "Rate limit exceeded",
72 message: "Too many requests. Please try again later.",
73 code: "RATE_LIMIT_EXCEEDED",
74 retry_after: retry_after
75 }))
76
:-(
|> halt()
77 end
78
79 @doc """
80 Initialize the rate limiter cache.
81 Call this in your application supervision tree.
82 """
83 def start_cache do
84
:-(
Cachex.start_link(
85 name: @cache_name,
86 limit: 50_000, # Maximum number of entries
87 expiration: Cachex.Expiration.default()
88 )
89 end
90 end
Line Hits Source