/__w/shurbej/shurbej/_build/test/cover/ct/shurbej_rate_limit.html

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}.
Line Hits Source