Skip to content

Commit 3f31b83

Browse files
committedOct 5, 2024
add rate limiting docs
1 parent ebeee9c commit 3f31b83

File tree

2 files changed

+150
-0
lines changed

2 files changed

+150
-0
lines changed
 

‎malta.config.json

+6
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@
3030
["Email and password with 2FA and WebAuthn", "/examples/email-password-2fa-webauthn"]
3131
]
3232
},
33+
{
34+
"title": "Rate limiting",
35+
"pages": [
36+
["Token bucket", "/rate-limit/token-bucket"]
37+
]
38+
},
3339
{
3440
"title": "Community",
3541
"pages": [

‎pages/rate-limit/token-bucket.md

+144
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
---
2+
title: "Token bucket"
3+
---
4+
5+
# Token bucket
6+
7+
Each user has their own bucket of tokens that gets refilled at a set interval. A token is removed on every request until none is left and the request is rejected. While a bit more complex than the fixed-window algorithm, it allows you to handle initial bursts and process requests more smoothly overall.
8+
9+
## Memory storage
10+
11+
This will only work if memory is persisted across requests. It won't work in serverless environments.
12+
13+
```ts
14+
export class TokenBucket<_Key> {
15+
public max: number;
16+
public refillIntervalSeconds: number;
17+
18+
constructor(max: number, refillIntervalSeconds: number) {
19+
this.max = max;
20+
this.refillIntervalSeconds = refillIntervalSeconds;
21+
}
22+
23+
private storage = new Map<_Key, Bucket>();
24+
25+
public consume(key: _Key, cost: number): boolean {
26+
let bucket = this.storage.get(key) ?? null;
27+
const now = Date.now();
28+
if (bucket === null) {
29+
bucket = {
30+
count: this.max - cost,
31+
refilledAt: now
32+
};
33+
this.storage.set(key, bucket);
34+
return true;
35+
}
36+
const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));
37+
bucket.count = Math.min(bucket.count + refill, this.max);
38+
bucket.refilledAt = now;
39+
if (bucket.count < cost) {
40+
return false;
41+
}
42+
bucket.count -= cost;
43+
this.storage.set(key, bucket);
44+
return true;
45+
}
46+
}
47+
48+
interface Bucket {
49+
count: number;
50+
refilledAt: number;
51+
}
52+
```
53+
54+
```ts
55+
// Bucket that has 10 tokens max and refills at a rate of 2 tokens/sec
56+
const bucket = new TokenBucket(10, 2);
57+
58+
if (!bucket.consume(ip, 1)) {
59+
throw new Error("Too many requests");
60+
}
61+
```
62+
63+
## Redis
64+
65+
We'll use Lua scripts to ensure queries are atomic.
66+
67+
```lua
68+
-- Returns 1 if allowed, 0 if not
69+
local key = KEYS[1]
70+
local max = tonumber(ARGV[1])
71+
local refillIntervalSeconds = tonumber(ARGV[2])
72+
local cost = tonumber(ARGV[3])
73+
local now = tonumber(ARGV[4]) -- Current unix time in seconds
74+
75+
local fields = redis.call("HGETALL", key)
76+
if #fields == 0 then
77+
redis.call("HSET", key, "count", max - cost, "refilled_at", now)
78+
return {1}
79+
end
80+
local count = 0
81+
local refilledAt = 0
82+
for i = 1, #fields, 2 do
83+
if fields[i] == "count" then
84+
count = tonumber(fields[i+1])
85+
elseif fields[i] == "refilled_at" then
86+
refilledAt = tonumber(fields[i+1])
87+
end
88+
end
89+
local refill = math.floor((now - refilledAt) / refillIntervalSeconds)
90+
count = math.min(count + refill, max)
91+
refilledAt = now
92+
if count < cost then
93+
return {0}
94+
end
95+
count = count - cost
96+
redis.call("HSET", key, "count", count, "refilled_at", now)
97+
return {1}
98+
```
99+
100+
Load the script and retrieve the script hash.
101+
102+
```ts
103+
const SCRIPT_SHA = await client.scriptLoad(script);
104+
```
105+
106+
Reference the script with the hash.
107+
108+
```ts
109+
export class TokenBucket {
110+
private storageKey: string;
111+
112+
public max: number;
113+
public refillIntervalSeconds: number;
114+
115+
constructor(storageKey: string, max: number, refillIntervalSeconds: number) {
116+
this.storageKey = storageKey;
117+
this.max = max;
118+
this.refillIntervalSeconds = refillIntervalSeconds;
119+
}
120+
121+
public async consume(key: _Key, cost: number): Promise<boolean> {
122+
const result = await client.EVALSHA(SCRIPT_SHA, {
123+
keys: [`${this.storageKey}:${key}`],
124+
arguments: [
125+
this.max.toString(),
126+
this.refillIntervalSeconds.toString(),
127+
cost.toString(),
128+
Math.floor(Date.now() / 1000).toString()
129+
]
130+
});
131+
return Boolean(result[0]);
132+
}
133+
}
134+
```
135+
136+
```ts
137+
// Bucket that has 10 tokens max and refills at a rate of 2 tokens/sec.
138+
// Ensure that the storage key is unique.
139+
const bucket = new TokenBucket("global_ip", 10, 2);
140+
141+
if (!bucket.consume(ip, 1)) {
142+
throw new Error("Too many requests");
143+
}
144+
```

0 commit comments

Comments
 (0)