| 1 |
|
-module(shurbej_rate_limit). |
| 2 |
|
-behaviour(gen_server). |
| 3 |
|
|
| 4 |
|
-export([start_link/0, check/1]). |
| 5 |
|
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]). |
| 6 |
|
|
| 7 |
|
-define(TABLE, shurbej_api_rate). |
| 8 |
|
-define(CLEANUP_INTERVAL, 60000). |
| 9 |
|
|
| 10 |
|
start_link() -> |
| 11 |
1 |
gen_server:start_link({local, ?MODULE}, ?MODULE, [], []). |
| 12 |
|
|
| 13 |
|
%% Check rate limit for a user. Direct ETS access (no gen_server bottleneck). |
| 14 |
|
%% Returns: |
| 15 |
|
%% ok — under soft threshold, no hint |
| 16 |
|
%% {backoff, Secs} — past soft threshold, advise client to pause |
| 17 |
|
%% {error, rate_limited, Retry} — past hard limit, 429 with Retry-After |
| 18 |
|
check(UserId) -> |
| 19 |
264 |
MaxReqs = application:get_env(shurbej, rate_limit_max, 1000), |
| 20 |
264 |
WindowSecs = application:get_env(shurbej, rate_limit_window, 60), |
| 21 |
264 |
SoftPct = application:get_env(shurbej, rate_limit_soft_pct, 80), |
| 22 |
264 |
Now = erlang:system_time(second), |
| 23 |
264 |
Window = Now div WindowSecs, |
| 24 |
264 |
Key = {UserId, Window}, |
| 25 |
264 |
Count = try ets:update_counter(?TABLE, Key, {2, 1}) |
| 26 |
|
catch error:badarg -> |
| 27 |
19 |
ets:insert_new(?TABLE, {Key, 0}), |
| 28 |
19 |
ets:update_counter(?TABLE, Key, {2, 1}) |
| 29 |
|
end, |
| 30 |
264 |
RetryAfter = WindowSecs - (Now rem WindowSecs), |
| 31 |
264 |
SoftCount = (MaxReqs * SoftPct) div 100, |
| 32 |
264 |
if |
| 33 |
|
Count > MaxReqs -> |
| 34 |
2 |
{error, rate_limited, RetryAfter}; |
| 35 |
|
Count > SoftCount -> |
| 36 |
2 |
{backoff, backoff_secs(Count, SoftCount, MaxReqs, WindowSecs)}; |
| 37 |
|
true -> |
| 38 |
260 |
ok |
| 39 |
|
end. |
| 40 |
|
|
| 41 |
|
%% Linear backoff from 1s at soft threshold to half the window at the hard limit. |
| 42 |
|
backoff_secs(Count, Soft, Hard, WindowSecs) when Hard > Soft -> |
| 43 |
2 |
MaxBackoff = max(1, WindowSecs div 2), |
| 44 |
2 |
Step = max(1, ((Count - Soft) * MaxBackoff) div max(1, Hard - Soft)), |
| 45 |
2 |
min(MaxBackoff, Step); |
| 46 |
|
backoff_secs(_, _, _, WindowSecs) -> |
| 47 |
:-( |
max(1, WindowSecs div 2). |
| 48 |
|
|
| 49 |
|
init([]) -> |
| 50 |
1 |
ets:new(?TABLE, [named_table, public, set, {write_concurrency, true}]), |
| 51 |
|
%% Children-count cache: one {LibRef, LibVersion, Counts} row per library; |
| 52 |
|
%% reused across requests and invalidated by version mismatch. |
| 53 |
1 |
ets:new(shurbej_children_cache, |
| 54 |
|
[named_table, public, set, {read_concurrency, true}, |
| 55 |
|
{write_concurrency, true}]), |
| 56 |
1 |
erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup), |
| 57 |
1 |
{ok, #{}}. |
| 58 |
|
|
| 59 |
:-( |
handle_call(_Msg, _From, State) -> {reply, ok, State}. |
| 60 |
:-( |
handle_cast(_Msg, State) -> {noreply, State}. |
| 61 |
|
|
| 62 |
|
handle_info(cleanup, State) -> |
| 63 |
:-( |
WindowSecs = application:get_env(shurbej, rate_limit_window, 60), |
| 64 |
:-( |
Now = erlang:system_time(second), |
| 65 |
:-( |
CurrentWindow = Now div WindowSecs, |
| 66 |
:-( |
ets:select_delete(?TABLE, [{{{'_', '$1'}, '_'}, [{'<', '$1', CurrentWindow}], [true]}]), |
| 67 |
:-( |
erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup), |
| 68 |
:-( |
{noreply, State}; |
| 69 |
|
handle_info(_Msg, State) -> |
| 70 |
:-( |
{noreply, State}. |