Current section

Files

Jump to
glimit src glimit.erl
Raw

src/glimit.erl

-module(glimit).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/glimit.gleam").
-export([new/0, per_second/2, per_second_fn/2, burst_limit/2, burst_limit_fn/2, max_idle/2, store/2, on_limit_exceeded/2, identifier/2, build/1, apply_built/2, apply/2, get_count/1, remove/2, sweep/1, apply2/2, apply3/2, apply4/2]).
-export_type([rate_limiter/3, rate_limiter_builder/3]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" This module provides a rate limiter that can be used to limit the number of\n"
" requests or function calls per second for a given identifier.\n"
"\n"
" A single rate limiter actor stores all token bucket state. Each hit is a single\n"
" message to the rate limiter, which performs the Token Bucket calculation inline.\n"
" A periodic sweep removes full or idle buckets to reduce memory usage. The\n"
" idle threshold defaults to 60 seconds and can be configured via `max_idle`.\n"
" The rate limiter fails open — if the rate limiter actor is unavailable,\n"
" requests are allowed through.\n"
"\n"
" The rate limits are configured using the following two options:\n"
"\n"
" - `per_second`: The rate of new available tokens per second. Think of this\n"
" as the steady state rate limit.\n"
" - `burst_limit`: The maximum number of available tokens. Think of this as\n"
" the burst rate limit. The default value is the `per_second` rate limit.\n"
"\n"
" The rate limiter can be applied to a function or handler using the `apply`\n"
" function, which returns a new function that checks the rate limit before\n"
" calling the original function.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.burst_limit(100)\n"
" |> glimit.identifier(fn(request) { request.ip })\n"
" |> glimit.on_limit_exceeded(fn(_request) { \"Rate limit reached\" })\n"
"\n"
" let handler =\n"
" fn(_request) { \"Hello, world!\" }\n"
" |> glimit.apply(limiter)\n"
" ```\n"
"\n"
" # Multi-argument functions\n"
"\n"
" `apply` wraps a single-argument function `fn(a) -> b`. To rate-limit a\n"
" function with multiple arguments, use `apply2`, `apply3`, or `apply4`:\n"
"\n"
" ```gleam\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.identifier(fn(args: #(String, String)) { args.0 })\n"
" |> glimit.on_limit_exceeded(fn(_args) { too_many_requests() })\n"
"\n"
" let limited_handle =\n"
" handle\n"
" |> glimit.apply2(limiter)\n"
"\n"
" limited_handle(\"user_123\", \"upload\")\n"
" ```\n"
"\n"
" # Pluggable store backend\n"
"\n"
" By default, rate limit state is stored in-memory using an OTP actor. For\n"
" distributed rate limiting (e.g. across multiple nodes), you can provide a\n"
" custom `Store` that persists bucket state externally (Redis, Postgres, etc.).\n"
"\n"
" All token bucket logic stays in glimit — adapters only implement simple\n"
" get/set/lock/unlock operations. The `glimit/bucket` module is public and\n"
" provides `to_pairs`/`from_pairs` helpers for serialization.\n"
"\n"
" ```gleam\n"
" import glimit\n"
" import glimit/bucket\n"
"\n"
" // Redis adapter example (using radish):\n"
" let store = glimit.Store(\n"
" get: fn(key) {\n"
" case radish.execute(client, [\"HGETALL\", key], 1000) {\n"
" Ok(fields) -> Ok(bucket.from_pairs(parse_hgetall_response(fields)))\n"
" Error(_) -> Error(Nil)\n"
" }\n"
" },\n"
" set: fn(key, state, ttl) {\n"
" let pairs = bucket.to_pairs(state) |> list.flat_map(fn(p) { [p.0, p.1] })\n"
" let _ = radish.execute(client, [\"HSET\", key, ..pairs], 1000)\n"
" let _ = radish.execute(client, [\"EXPIRE\", key, int.to_string(ttl)], 1000)\n"
" Ok(Nil)\n"
" },\n"
" lock: fn(key) {\n"
" case radish.execute(client, [\"SET\", key <> \":lock\", \"1\", \"NX\", \"EX\", \"5\"], 1000) {\n"
" Ok(_) -> Ok(Nil)\n"
" Error(_) -> Error(Nil)\n"
" }\n"
" },\n"
" unlock: fn(key) {\n"
" let _ = radish.execute(client, [\"DEL\", key <> \":lock\"], 1000)\n"
" Ok(Nil)\n"
" },\n"
" )\n"
"\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.store(store)\n"
" |> glimit.identifier(fn(req) { req.ip })\n"
" |> glimit.on_limit_exceeded(fn(_) { \"Rate limited\" })\n"
" |> glimit.apply(handler)\n"
" ```\n"
"\n"
).
-type rate_limiter(GDJ, GDK, GDL) :: {rate_limiter,
gleam@erlang@process:subject(glimit@rate_limiter:message(GDL)),
fun((GDJ) -> GDK),
fun((GDJ) -> GDL),
gleam@option:option(glimit@memory_store:memory_store())}.
-type rate_limiter_builder(GDM, GDN, GDO) :: {rate_limiter_builder,
gleam@option:option(fun((GDO) -> integer())),
gleam@option:option(fun((GDO) -> integer())),
gleam@option:option(fun((GDM) -> GDO)),
gleam@option:option(fun((GDM) -> GDN)),
gleam@option:option(integer()),
gleam@option:option(glimit@bucket:store())}.
-file("src/glimit.gleam", 151).
?DOC(" Create a new rate limiter builder.\n").
-spec new() -> rate_limiter_builder(any(), any(), any()).
new() ->
{rate_limiter_builder, none, none, none, none, {some, 60000}, none}.
-file("src/glimit.gleam", 181).
?DOC(
" Set the rate of new available tokens per second.\n"
"\n"
" Note that this is not the maximum number of requests that can be made in a single\n"
" second, but the rate at which tokens are added to the bucket. Think of this as the\n"
" steady state rate limit, while the `burst_limit` function sets the maximum number of\n"
" available tokens (or the burst rate limit).\n"
"\n"
" This value is also used as the default value for the `burst_limit` function.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" ```\n"
).
-spec per_second(rate_limiter_builder(GDV, GDW, GDX), integer()) -> rate_limiter_builder(GDV, GDW, GDX).
per_second(Limiter, Limit) ->
{rate_limiter_builder,
{some, fun(_) -> Limit end},
erlang:element(3, Limiter),
erlang:element(4, Limiter),
erlang:element(5, Limiter),
erlang:element(6, Limiter),
erlang:element(7, Limiter)}.
-file("src/glimit.gleam", 208).
?DOC(
" Set the rate limit per second, based on the identifier.\n"
"\n"
" Note: this function is evaluated once when a bucket is first created for an\n"
" identifier. If the function returns a different value later, existing buckets\n"
" are not affected until they are swept (due to idleness or being full) and\n"
" re-created on the next hit.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.identifier(fn(request) { request.user_id })\n"
" |> glimit.per_second_fn(fn(user_id) {\n"
" db.get_rate_limit(user_id)\n"
" })\n"
" ```\n"
).
-spec per_second_fn(
rate_limiter_builder(GEE, GEF, GEG),
fun((GEG) -> integer())
) -> rate_limiter_builder(GEE, GEF, GEG).
per_second_fn(Limiter, Limit_fn) ->
{rate_limiter_builder,
{some, Limit_fn},
erlang:element(3, Limiter),
erlang:element(4, Limiter),
erlang:element(5, Limiter),
erlang:element(6, Limiter),
erlang:element(7, Limiter)}.
-file("src/glimit.gleam", 232).
?DOC(
" Set the maximum number of available tokens.\n"
"\n"
" The maximum number of available tokens is the maximum number of requests that can be\n"
" made in a single burst when the bucket is full. The default value is the same as the\n"
" rate limit per second.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.burst_limit(100)\n"
" ```\n"
).
-spec burst_limit(rate_limiter_builder(GEN, GEO, GEP), integer()) -> rate_limiter_builder(GEN, GEO, GEP).
burst_limit(Limiter, Burst_limit) ->
{rate_limiter_builder,
erlang:element(2, Limiter),
{some, fun(_) -> Burst_limit end},
erlang:element(4, Limiter),
erlang:element(5, Limiter),
erlang:element(6, Limiter),
erlang:element(7, Limiter)}.
-file("src/glimit.gleam", 260).
?DOC(
" Set the maximum number of available tokens, based on the identifier.\n"
"\n"
" Note: this function is evaluated once when a bucket is first created for an\n"
" identifier. If the function returns a different value later, existing buckets\n"
" are not affected until they are swept (due to idleness or being full) and\n"
" re-created on the next hit.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.identifier(fn(request) { request.user_id })\n"
" |> glimit.per_second(10)\n"
" |> glimit.burst_limit_fn(fn(user_id) {\n"
" db.get_burst_limit(user_id)\n"
" })\n"
" ```\n"
).
-spec burst_limit_fn(
rate_limiter_builder(GEW, GEX, GEY),
fun((GEY) -> integer())
) -> rate_limiter_builder(GEW, GEX, GEY).
burst_limit_fn(Limiter, Burst_limit_fn) ->
{rate_limiter_builder,
erlang:element(2, Limiter),
{some, Burst_limit_fn},
erlang:element(4, Limiter),
erlang:element(5, Limiter),
erlang:element(6, Limiter),
erlang:element(7, Limiter)}.
-file("src/glimit.gleam", 290).
?DOC(
" Set the idle eviction threshold in seconds.\n"
"\n"
" Buckets that have not been hit for longer than this duration are removed\n"
" during periodic sweeps. The default is 60 seconds. Set to `0` to disable\n"
" idle eviction entirely.\n"
"\n"
" For rate limiters with a high `burst_limit` relative to `per_second`, you\n"
" may want to increase this value so that partially-refilled buckets are not\n"
" evicted prematurely. A good rule of thumb is\n"
" `burst_limit / per_second` seconds.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(1)\n"
" |> glimit.burst_limit(1000)\n"
" |> glimit.max_idle(1000)\n"
" ```\n"
).
-spec max_idle(rate_limiter_builder(GFF, GFG, GFH), integer()) -> rate_limiter_builder(GFF, GFG, GFH).
max_idle(Limiter, Seconds) ->
case Seconds of
S when S =< 0 ->
{rate_limiter_builder,
erlang:element(2, Limiter),
erlang:element(3, Limiter),
erlang:element(4, Limiter),
erlang:element(5, Limiter),
none,
erlang:element(7, Limiter)};
S@1 ->
{rate_limiter_builder,
erlang:element(2, Limiter),
erlang:element(3, Limiter),
erlang:element(4, Limiter),
erlang:element(5, Limiter),
{some, S@1 * 1000},
erlang:element(7, Limiter)}
end.
-file("src/glimit.gleam", 319).
?DOC(
" Set a pluggable store backend for distributed rate limiting.\n"
"\n"
" When a store is configured, bucket state is read from and written to the\n"
" store on each hit instead of being kept in the actor's in-memory dictionary.\n"
" The periodic sweep becomes a no-op since external stores handle expiry via TTL.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.store(my_redis_store)\n"
" |> glimit.identifier(fn(request) { request.ip })\n"
" |> glimit.on_limit_exceeded(fn(_request) { \"Rate limit reached\" })\n"
" ```\n"
).
-spec store(rate_limiter_builder(GFO, GFP, GFQ), glimit@bucket:store()) -> rate_limiter_builder(GFO, GFP, GFQ).
store(Limiter, Store) ->
{rate_limiter_builder,
erlang:element(2, Limiter),
erlang:element(3, Limiter),
erlang:element(4, Limiter),
erlang:element(5, Limiter),
erlang:element(6, Limiter),
{some, Store}}.
-file("src/glimit.gleam", 339).
?DOC(
" Set the handler to be called when the rate limit is reached.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.on_limit_exceeded(fn(_request) { \"Rate limit reached\" })\n"
" ```\n"
).
-spec on_limit_exceeded(rate_limiter_builder(GFX, GFY, GFZ), fun((GFX) -> GFY)) -> rate_limiter_builder(GFX, GFY, GFZ).
on_limit_exceeded(Limiter, On_limit_exceeded) ->
{rate_limiter_builder,
erlang:element(2, Limiter),
erlang:element(3, Limiter),
erlang:element(4, Limiter),
{some, On_limit_exceeded},
erlang:element(6, Limiter),
erlang:element(7, Limiter)}.
-file("src/glimit.gleam", 358).
?DOC(
" Set the identifier function to be used to identify the rate limit.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" import glimit\n"
"\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.identifier(fn(request) { request.ip })\n"
" ```\n"
).
-spec identifier(rate_limiter_builder(GGG, GGH, GGI), fun((GGG) -> GGI)) -> rate_limiter_builder(GGG, GGH, GGI).
identifier(Limiter, Identifier) ->
{rate_limiter_builder,
erlang:element(2, Limiter),
erlang:element(3, Limiter),
{some, Identifier},
erlang:element(5, Limiter),
erlang:element(6, Limiter),
erlang:element(7, Limiter)}.
-file("src/glimit.gleam", 374).
?DOC(
" Build the rate limiter.\n"
"\n"
" Note that using `apply` will already build the rate limiter, so this function is\n"
" only useful if you want to build the rate limiter manually and apply it to multiple\n"
" functions.\n"
"\n"
" To apply the resulting rate limiter to a function or handler, use the `apply_built`\n"
" function.\n"
).
-spec build(rate_limiter_builder(GGP, GGQ, GGR)) -> {ok,
rate_limiter(GGP, GGQ, GGR)} |
{error, binary()}.
build(Config) ->
gleam@result:'try'(case erlang:element(2, Config) of
{some, Per_second} ->
{ok, Per_second};
none ->
{error, <<"`per_second` rate limit is required"/utf8>>}
end, fun(Per_second@1) ->
Burst_limit@1 = case erlang:element(3, Config) of
{some, Burst_limit} ->
Burst_limit;
none ->
Per_second@1
end,
gleam@result:'try'(case erlang:element(4, Config) of
{some, Identifier} ->
{ok, Identifier};
none ->
{error, <<"`identifier` function is required"/utf8>>}
end, fun(Identifier@1) ->
gleam@result:'try'(case erlang:element(5, Config) of
{some, On_limit_exceeded} ->
{ok, On_limit_exceeded};
none ->
{error,
<<"`on_limit_exceeded` function is required"/utf8>>}
end, fun(On_limit_exceeded@1) ->
gleam@result:'try'(case erlang:element(7, Config) of
{some, S} ->
{ok, {S, none}};
none ->
case glimit@memory_store:new(
erlang:element(6, Config),
10000
) of
{ok, {S@1, Handle}} ->
{ok, {S@1, {some, Handle}}};
{error, _} ->
{error,
<<"Failed to start memory store"/utf8>>}
end
end, fun(_use0) ->
{Resolved_store, Mem_store} = _use0,
gleam@result:'try'(
begin
_pipe = glimit@rate_limiter:new(
Per_second@1,
Burst_limit@1,
Resolved_store
),
gleam@result:map_error(
_pipe,
fun(_) ->
<<"Failed to start rate limiter"/utf8>>
end
)
end,
fun(Rate_limiter_actor) ->
{ok,
{rate_limiter,
Rate_limiter_actor,
On_limit_exceeded@1,
Identifier@1,
Mem_store}}
end
)
end)
end)
end)
end).
-file("src/glimit.gleam", 439).
?DOC(
" Apply the rate limiter to a request handler or function.\n"
"\n"
" This function is useful if you want to build the rate limiter manually using the\n"
" `build` function.\n"
).
-spec apply_built(fun((GHG) -> GHH), rate_limiter(GHG, GHH, any())) -> fun((GHG) -> GHH).
apply_built(Func, Limiter) ->
fun(Input) ->
Identifier = (erlang:element(4, Limiter))(Input),
case glimit@rate_limiter:hit(erlang:element(2, Limiter), Identifier) of
{ok, nil} ->
Func(Input);
{error, rate_limited} ->
(erlang:element(3, Limiter))(Input);
{error, unavailable} ->
Func(Input);
{error, store_lock_failed} ->
Func(Input)
end
end.
-file("src/glimit.gleam", 423).
?DOC(
" Apply the rate limiter to a request handler or function.\n"
"\n"
" Panics if the rate limiter cannot be started or if the `identifier`\n"
" function or `on_limit_exceeded` function is missing.\n"
).
-spec apply(fun((GHA) -> GHB), rate_limiter_builder(GHA, GHB, any())) -> fun((GHA) -> GHB).
apply(Func, Config) ->
Limiter@1 = case build(Config) of
{ok, Limiter} ->
Limiter;
{error, Message} ->
erlang:error(#{gleam_error => panic,
message => Message,
file => <<?FILEPATH/utf8>>,
module => <<"glimit"/utf8>>,
function => <<"apply"/utf8>>,
line => 429})
end,
apply_built(Func, Limiter@1).
-file("src/glimit.gleam", 458).
?DOC(
" Return the number of tracked identifiers in the in-memory store.\n"
"\n"
" Returns 0 if the rate limiter uses an external store.\n"
).
-spec get_count(rate_limiter(any(), any(), any())) -> integer().
get_count(Limiter) ->
case erlang:element(5, Limiter) of
{some, Ms} ->
glimit@memory_store:get_count(Ms);
none ->
0
end.
-file("src/glimit.gleam", 469).
?DOC(
" Remove an identifier from the in-memory store.\n"
"\n"
" No-op if the rate limiter uses an external store.\n"
).
-spec remove(rate_limiter(any(), any(), GHU), GHU) -> nil.
remove(Limiter, Identifier) ->
case erlang:element(5, Limiter) of
{some, Ms} ->
Key = <<"glimit:"/utf8, (gleam@string:inspect(Identifier))/binary>>,
_ = glimit@memory_store:remove(Ms, Key),
nil;
none ->
nil
end.
-file("src/glimit.gleam", 484).
?DOC(
" Remove full or idle buckets from the in-memory store synchronously.\n"
"\n"
" No-op if the rate limiter uses an external store.\n"
).
-spec sweep(rate_limiter(any(), any(), any())) -> nil.
sweep(Limiter) ->
case erlang:element(5, Limiter) of
{some, _} ->
nil;
none ->
nil
end.
-file("src/glimit.gleam", 511).
?DOC(
" Apply the rate limiter to a 2-argument function.\n"
"\n"
" The config's `identifier` and `on_limit_exceeded` receive a `#(a, b)` tuple.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.identifier(fn(args: #(String, String)) { args.0 })\n"
" |> glimit.on_limit_exceeded(fn(_) { \"Rate limited\" })\n"
"\n"
" let limited =\n"
" handle\n"
" |> glimit.apply2(limiter)\n"
"\n"
" limited(\"user_123\", \"upload\")\n"
" ```\n"
).
-spec apply2(
fun((GIE, GIF) -> GIG),
rate_limiter_builder({GIE, GIF}, GIG, any())
) -> fun((GIE, GIF) -> GIG).
apply2(Func, Config) ->
Wrapped = begin
_pipe = fun(Args) ->
Func(erlang:element(1, Args), erlang:element(2, Args))
end,
apply(_pipe, Config)
end,
fun(A, B) -> Wrapped({A, B}) end.
-file("src/glimit.gleam", 541).
?DOC(
" Apply the rate limiter to a 3-argument function.\n"
"\n"
" The config's `identifier` and `on_limit_exceeded` receive a `#(a, b, c)` tuple.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.identifier(fn(args: #(String, String, Int)) { args.0 })\n"
" |> glimit.on_limit_exceeded(fn(_) { \"Rate limited\" })\n"
"\n"
" let limited =\n"
" handle\n"
" |> glimit.apply3(limiter)\n"
"\n"
" limited(\"user_123\", \"upload\", 42)\n"
" ```\n"
).
-spec apply3(
fun((GIL, GIM, GIN) -> GIO),
rate_limiter_builder({GIL, GIM, GIN}, GIO, any())
) -> fun((GIL, GIM, GIN) -> GIO).
apply3(Func, Config) ->
Wrapped = begin
_pipe = fun(Args) ->
Func(
erlang:element(1, Args),
erlang:element(2, Args),
erlang:element(3, Args)
)
end,
apply(_pipe, Config)
end,
fun(A, B, C) -> Wrapped({A, B, C}) end.
-file("src/glimit.gleam", 571).
?DOC(
" Apply the rate limiter to a 4-argument function.\n"
"\n"
" The config's `identifier` and `on_limit_exceeded` receive a `#(a, b, c, d)` tuple.\n"
"\n"
" # Example\n"
"\n"
" ```gleam\n"
" let limiter =\n"
" glimit.new()\n"
" |> glimit.per_second(10)\n"
" |> glimit.identifier(fn(args: #(String, String, Int, Bool)) { args.0 })\n"
" |> glimit.on_limit_exceeded(fn(_) { \"Rate limited\" })\n"
"\n"
" let limited =\n"
" handle\n"
" |> glimit.apply4(limiter)\n"
"\n"
" limited(\"user_123\", \"upload\", 42, True)\n"
" ```\n"
).
-spec apply4(
fun((GIT, GIU, GIV, GIW) -> GIX),
rate_limiter_builder({GIT, GIU, GIV, GIW}, GIX, any())
) -> fun((GIT, GIU, GIV, GIW) -> GIX).
apply4(Func, Config) ->
Wrapped = begin
_pipe = fun(Args) ->
Func(
erlang:element(1, Args),
erlang:element(2, Args),
erlang:element(3, Args),
erlang:element(4, Args)
)
end,
apply(_pipe, Config)
end,
fun(A, B, C, D) -> Wrapped({A, B, C, D}) end.