+23
.github/workflows/test.yml
+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
+78
README.md
+78
README.md
···
1
+
# glimit
2
+
3
+
[](https://hex.pm/packages/glimit)
4
+
[](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
+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
+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
+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
+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
+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
+
}