this repo has no description

🌄 Initial commit

Joris Hartog 2aa06f5b

+23
.github/workflows/test.yml
··· 1 + name: test 2 + 3 + on: 4 + push: 5 + branches: 6 + - master 7 + - main 8 + pull_request: 9 + 10 + jobs: 11 + test: 12 + runs-on: ubuntu-latest 13 + steps: 14 + - uses: actions/checkout@v4 15 + - uses: erlef/setup-beam@v1 16 + with: 17 + otp-version: "26.0.2" 18 + gleam-version: "1.4.1" 19 + rebar3-version: "3" 20 + # elixir-version: "1.15.4" 21 + - run: gleam deps download 22 + - run: gleam test 23 + - run: gleam format --check src test
+4
.gitignore
··· 1 + *.beam 2 + *.ez 3 + /build 4 + erl_crash.dump
+78
README.md
··· 1 + # glimit 2 + 3 + [![Package Version](https://img.shields.io/hexpm/v/glimit)](https://hex.pm/packages/glimit) 4 + [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/glimit/) 5 + 6 + A framework-agnostic rate limiter for Gleam. 💫 7 + 8 + > ⚠️ This library is still in development, use at your own risk. 9 + 10 + 11 + ## Installation 12 + 13 + ```sh 14 + gleam add glimit 15 + ``` 16 + 17 + 18 + ## Example usage 19 + 20 + Glimit could be used to rate limit requests to a mist HTTP server: 21 + 22 + ```gleam 23 + import glimit 24 + import gleam/bytes_builder 25 + 26 + 27 + fn handle_request(req: Request(Connection)) -> Response(ResponseData) { 28 + let index = 29 + response.new(200) 30 + |> response.set_body(mist.Bytes(bytes_builder.new())) 31 + let not_found = 32 + response.new(404) 33 + |> response.set_body(mist.Bytes(bytes_builder.new())) 34 + 35 + case request.path_segments(req) { 36 + [] -> index 37 + _ -> not_found 38 + } 39 + } 40 + 41 + pub fn main() { 42 + let limiter = 43 + glimit.new() 44 + |> glimit.per_second(10) 45 + |> glimit.per_minute(100) 46 + |> glimit.per_hour(1000) 47 + |> glimit.identifier(fn(req: Request(Connection)) { 48 + req.body 49 + |> get_client_info 50 + |> result.map(fn(client_info: ConnectionInfo) { 51 + client_info.ip_address |> string.inspect 52 + }) 53 + |> result.unwrap("unknown IP address") 54 + }) 55 + |> glimit.handler(fn(_req) { 56 + response.new(429) 57 + |> response.set_body(mist.Bytes(bytes_builder.new())) 58 + }) 59 + |> glimit.build 60 + 61 + let assert Ok(_) = 62 + handle_request 63 + |> glimit.apply(limiter) 64 + |> mist.new 65 + |> mist.port(8080) 66 + |> mist.start_http 67 + 68 + process.sleep_forever() 69 + } 70 + ``` 71 + 72 + Further documentation can be found at <https://hexdocs.pm/glimit>. 73 + 74 + ## Development 75 + 76 + ```sh 77 + gleam test # Run the tests 78 + ```
+15
gleam.toml
··· 1 + name = "glimit" 2 + version = "1.0.0" 3 + 4 + description = "A framework-agnostic rate limiter for Gleam." 5 + licences = ["MIT"] 6 + repository = { type = "github", user = "nootr", repo = "glimit" } 7 + target = "erlang" 8 + 9 + [dependencies] 10 + gleam_stdlib = ">= 0.34.0 and < 2.0.0" 11 + gleam_erlang = ">= 0.25.0 and < 1.0.0" 12 + gleam_otp = ">= 0.11.2 and < 1.0.0" 13 + 14 + [dev-dependencies] 15 + gleeunit = ">= 1.0.0 and < 2.0.0"
+15
manifest.toml
··· 1 + # This file was generated by Gleam 2 + # You typically do not need to edit this file 3 + 4 + packages = [ 5 + { name = "gleam_erlang", version = "0.25.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "054D571A7092D2A9727B3E5D183B7507DAB0DA41556EC9133606F09C15497373" }, 6 + { name = "gleam_otp", version = "0.11.2", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "517FFB679E44AD71D059F3EF6A17BA6EFC8CB94FA174D52E22FB6768CF684D78" }, 7 + { name = "gleam_stdlib", version = "0.40.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86606B75A600BBD05E539EB59FABC6E307EEEA7B1E5865AFB6D980A93BCB2181" }, 8 + { name = "gleeunit", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "F7A7228925D3EE7D0813C922E062BFD6D7E9310F0BEE585D3A42F3307E3CFD13" }, 9 + ] 10 + 11 + [requirements] 12 + gleam_erlang = { version = ">= 0.25.0 and < 1.0.0" } 13 + gleam_otp = { version = ">= 0.11.2 and < 1.0.0" } 14 + gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" } 15 + gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
+209
src/glimit.gleam
··· 1 + //// A framework-agnostic rate limiter. 2 + //// 3 + 4 + import gleam/dict 5 + import gleam/erlang/process.{type Subject} 6 + import gleam/list 7 + import gleam/option.{type Option, None, Some} 8 + import gleam/otp/actor 9 + import gleam/result 10 + import glimit/utils 11 + 12 + /// The messages that the actor can receive. 13 + /// 14 + pub type Message(a) { 15 + /// Stop the actor. 16 + Shutdown 17 + 18 + /// Mark a hit for a given identifier. 19 + Hit(input: a, reply_with: Subject(Result(Nil, Nil))) 20 + } 21 + 22 + /// The rate limiter's public interface. 23 + /// 24 + pub type RateLimiter(a, b) { 25 + RateLimiter(subject: Subject(Message(a)), handler: fn(a) -> b) 26 + } 27 + 28 + /// A rate limiter. 29 + /// 30 + pub type RateLimiterBuilder(a, b, id) { 31 + RateLimiterBuilder( 32 + per_second: Option(Int), 33 + per_minute: Option(Int), 34 + per_hour: Option(Int), 35 + identifier: fn(a) -> id, 36 + handler: fn(a) -> b, 37 + ) 38 + } 39 + 40 + /// The actor state of the actor. 41 + /// 42 + /// The state is a dictionary where the key is the identifier and the value is a list of epoch timestamps. 43 + /// 44 + pub type State(a, b, id) { 45 + RateLimiterState( 46 + hit_log: dict.Dict(id, List(Int)), 47 + per_second: Option(Int), 48 + per_minute: Option(Int), 49 + per_hour: Option(Int), 50 + identifier: fn(a) -> id, 51 + handler: fn(a) -> b, 52 + ) 53 + } 54 + 55 + fn handle_message( 56 + message: Message(a), 57 + state: State(a, b, id), 58 + ) -> actor.Next(Message(a), State(a, b, id)) { 59 + case message { 60 + Shutdown -> actor.Stop(process.Normal) 61 + Hit(input, client) -> { 62 + let identifier = state.identifier(input) 63 + 64 + // Update hit log 65 + let timestamp = utils.now() 66 + let hits = 67 + state.hit_log 68 + |> dict.get(identifier) 69 + |> result.unwrap([]) 70 + |> list.filter(fn(hit) { hit >= timestamp - 60 * 60 }) 71 + |> list.append([timestamp]) 72 + let hit_log = 73 + state.hit_log 74 + |> dict.insert(identifier, hits) 75 + let state = RateLimiterState(..state, hit_log: hit_log) 76 + 77 + // Check rate limits 78 + // TODO: optimize into a single loop 79 + let hits_last_hour = hits |> list.length() 80 + 81 + let hits_last_minute = 82 + hits 83 + |> list.filter(fn(hit) { hit >= timestamp - 60 }) 84 + |> list.length() 85 + 86 + let hits_last_second = 87 + hits 88 + |> list.filter(fn(hit) { hit >= timestamp - 1 }) 89 + |> list.length() 90 + 91 + let limit_reached = { 92 + case state.per_hour { 93 + Some(limit) -> hits_last_hour > limit 94 + None -> False 95 + } 96 + || case state.per_minute { 97 + Some(limit) -> hits_last_minute > limit 98 + None -> False 99 + } 100 + || case state.per_second { 101 + Some(limit) -> hits_last_second > limit 102 + None -> False 103 + } 104 + } 105 + 106 + case limit_reached { 107 + True -> process.send(client, Error(Nil)) 108 + False -> process.send(client, Ok(Nil)) 109 + } 110 + 111 + actor.continue(state) 112 + } 113 + } 114 + } 115 + 116 + /// Create a new rate limiter builder. 117 + /// 118 + /// Panics when the rate limit hit counter cannot be created. 119 + /// 120 + pub fn new() -> RateLimiterBuilder(a, b, id) { 121 + RateLimiterBuilder( 122 + per_second: None, 123 + per_minute: None, 124 + per_hour: None, 125 + identifier: fn(_) { panic as "No identifier configured" }, 126 + handler: fn(_) { panic as "Rate limit reached" }, 127 + ) 128 + } 129 + 130 + /// Set the rate limit per second. 131 + /// 132 + pub fn per_second( 133 + limiter: RateLimiterBuilder(a, b, id), 134 + limit: Int, 135 + ) -> RateLimiterBuilder(a, b, id) { 136 + RateLimiterBuilder(..limiter, per_second: Some(limit)) 137 + } 138 + 139 + /// Set the rate limit per minute. 140 + /// 141 + pub fn per_minute( 142 + limiter: RateLimiterBuilder(a, b, id), 143 + limit: Int, 144 + ) -> RateLimiterBuilder(a, b, id) { 145 + RateLimiterBuilder(..limiter, per_minute: Some(limit)) 146 + } 147 + 148 + /// Set the rate limit per hour. 149 + /// 150 + pub fn per_hour( 151 + limiter: RateLimiterBuilder(a, b, id), 152 + limit: Int, 153 + ) -> RateLimiterBuilder(a, b, id) { 154 + RateLimiterBuilder(..limiter, per_hour: Some(limit)) 155 + } 156 + 157 + /// Set the handler to be called when the rate limit is reached. 158 + /// 159 + pub fn handler( 160 + limiter: RateLimiterBuilder(a, b, id), 161 + handler: fn(a) -> b, 162 + ) -> RateLimiterBuilder(a, b, id) { 163 + RateLimiterBuilder(..limiter, handler: handler) 164 + } 165 + 166 + /// Set the identifier function to be used to identify the rate limit. 167 + /// 168 + pub fn identifier( 169 + limiter: RateLimiterBuilder(a, b, id), 170 + identifier: fn(a) -> id, 171 + ) -> RateLimiterBuilder(a, b, id) { 172 + RateLimiterBuilder(..limiter, identifier: identifier) 173 + } 174 + 175 + /// Build the rate limiter. 176 + /// 177 + pub fn build(config: RateLimiterBuilder(a, b, id)) -> RateLimiter(a, b) { 178 + let state = 179 + RateLimiterState( 180 + hit_log: dict.new(), 181 + per_second: config.per_second, 182 + per_minute: config.per_minute, 183 + per_hour: config.per_hour, 184 + identifier: config.identifier, 185 + handler: config.handler, 186 + ) 187 + let subject = case actor.start(state, handle_message) { 188 + Ok(actor) -> actor 189 + Error(_) -> panic as "Failed to start rate limiter actor" 190 + } 191 + RateLimiter(subject: subject, handler: config.handler) 192 + } 193 + 194 + /// Apply the rate limiter to a request handler or function. 195 + /// 196 + pub fn apply(func: fn(a) -> b, limiter: RateLimiter(a, b)) -> fn(a) -> b { 197 + fn(input: a) -> b { 198 + case actor.call(limiter.subject, Hit(input, _), 10) { 199 + Ok(Nil) -> func(input) 200 + Error(Nil) -> limiter.handler(input) 201 + } 202 + } 203 + } 204 + 205 + /// Stop the rate limiter agent. 206 + /// 207 + pub fn stop(limiter: RateLimiter(a, b)) { 208 + actor.send(limiter.subject, Shutdown) 209 + }
+11
src/glimit/utils.gleam
··· 1 + //// A module containing utility functions. 2 + //// 3 + 4 + @external(erlang, "os", "timestamp") 5 + fn now_erlang() -> #(Int, Int, Int) 6 + 7 + /// Get the current time in epoch seconds. 8 + pub fn now() -> Int { 9 + let #(megaseconds, seconds, _) = now_erlang() 10 + megaseconds * 1_000_000 + seconds 11 + }
+81
test/glimit_test.gleam
··· 1 + import gleeunit 2 + import gleeunit/should 3 + import glimit 4 + 5 + pub fn main() { 6 + gleeunit.main() 7 + } 8 + 9 + pub fn single_argument_function_different_ids_test() { 10 + let limiter = 11 + glimit.new() 12 + |> glimit.per_second(2) 13 + |> glimit.identifier(fn(x) { x }) 14 + |> glimit.handler(fn(_) { "Stop!" }) 15 + |> glimit.build 16 + 17 + let func = 18 + fn(_x) { "OK" } 19 + |> glimit.apply(limiter) 20 + 21 + func("a") |> should.equal("OK") 22 + func("b") |> should.equal("OK") 23 + func("b") |> should.equal("OK") 24 + func("b") |> should.equal("Stop!") 25 + func("a") |> should.equal("OK") 26 + func("a") |> should.equal("Stop!") 27 + } 28 + 29 + pub fn single_argument_function_per_second_test() { 30 + let limiter = 31 + glimit.new() 32 + |> glimit.per_second(2) 33 + |> glimit.identifier(fn(_) { "id" }) 34 + |> glimit.handler(fn(_) { "Stop!" }) 35 + |> glimit.build 36 + 37 + let func = 38 + fn(_x) { "OK" } 39 + |> glimit.apply(limiter) 40 + 41 + func(Nil) |> should.equal("OK") 42 + func(Nil) |> should.equal("OK") 43 + func(Nil) |> should.equal("Stop!") 44 + func(Nil) |> should.equal("Stop!") 45 + } 46 + 47 + pub fn single_argument_function_per_minute_test() { 48 + let limiter = 49 + glimit.new() 50 + |> glimit.per_minute(2) 51 + |> glimit.identifier(fn(_) { "id" }) 52 + |> glimit.handler(fn(_) { "Stop!" }) 53 + |> glimit.build 54 + 55 + let func = 56 + fn(_x) { "OK" } 57 + |> glimit.apply(limiter) 58 + 59 + func(Nil) |> should.equal("OK") 60 + func(Nil) |> should.equal("OK") 61 + func(Nil) |> should.equal("Stop!") 62 + func(Nil) |> should.equal("Stop!") 63 + } 64 + 65 + pub fn single_argument_function_per_hour_test() { 66 + let limiter = 67 + glimit.new() 68 + |> glimit.per_hour(2) 69 + |> glimit.identifier(fn(_) { "id" }) 70 + |> glimit.handler(fn(_) { "Stop!" }) 71 + |> glimit.build 72 + 73 + let func = 74 + fn(_x) { "OK" } 75 + |> glimit.apply(limiter) 76 + 77 + func(Nil) |> should.equal("OK") 78 + func(Nil) |> should.equal("OK") 79 + func(Nil) |> should.equal("Stop!") 80 + func(Nil) |> should.equal("Stop!") 81 + }