Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.

Commit 8c83495

Browse files
authored
refactor(rate_limit): migrate to actors (#116)
1 parent 28f260f commit 8c83495

File tree

4 files changed

+93
-79
lines changed

4 files changed

+93
-79
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { ActorBase, ActorContext } from "../module.gen.ts";
2+
3+
type Input = undefined;
4+
5+
interface State {
6+
tokens: number;
7+
lastRefillTimestamp: number;
8+
}
9+
10+
export interface ThrottleRequest {
11+
requests: number;
12+
period: number;
13+
}
14+
15+
export interface ThrottleResponse {
16+
success: boolean;
17+
refillAt: number;
18+
}
19+
20+
export class Actor extends ActorBase<undefined, State> {
21+
public initialize(): State {
22+
// Will refill on first call of `throttle`
23+
return {
24+
tokens: 0,
25+
lastRefillTimestamp: 0,
26+
};
27+
}
28+
29+
throttle(_ctx: ActorContext, req: ThrottleRequest): ThrottleResponse {
30+
// Reset bucket
31+
const now = Date.now();
32+
if (now > this.state.lastRefillTimestamp + req.period * 1000) {
33+
this.state.tokens = req.requests;
34+
this.state.lastRefillTimestamp = now;
35+
}
36+
37+
// Attempt to consume token
38+
const success = this.state.tokens >= 1;
39+
if (success) {
40+
this.state.tokens -= 1;
41+
}
42+
43+
const refillAt = Math.ceil((1 - this.state.tokens) * (req.period / req.requests));
44+
return { success, refillAt };
45+
}
46+
}

modules/rate_limit/module.json

Lines changed: 30 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
11
{
2-
"name": "Rate Limit",
3-
"description": "Prevent abuse by limiting request rate.",
4-
"icon": "gauge-circle-minus",
5-
"tags": [
6-
"core",
7-
"utility"
8-
],
9-
"authors": [
10-
"rivet-gg",
11-
"NathanFlurry"
12-
],
13-
"status": "stable",
14-
"scripts": {
15-
"throttle": {
16-
"name": "Throttle",
17-
"description": "Limit the amount of times an request can be made by a given key."
18-
},
19-
"throttle_public": {
20-
"name": "Throttle Public",
21-
"description": "Limit the amount of times a public request can be made by a given key. This will rate limit based off the user's IP address."
22-
}
23-
},
24-
"errors": {
25-
"rate_limit_exceeded": {
26-
"name": "Rate Limit Exceeded"
27-
}
28-
}
2+
"name": "Rate Limit",
3+
"description": "Prevent abuse by limiting request rate.",
4+
"icon": "gauge-circle-minus",
5+
"tags": [
6+
"core",
7+
"utility"
8+
],
9+
"authors": [
10+
"rivet-gg",
11+
"NathanFlurry"
12+
],
13+
"status": "stable",
14+
"scripts": {
15+
"throttle": {
16+
"name": "Throttle",
17+
"description": "Limit the amount of times an request can be made by a given key."
18+
},
19+
"throttle_public": {
20+
"name": "Throttle Public",
21+
"description": "Limit the amount of times a public request can be made by a given key. This will rate limit based off the user's IP address."
22+
}
23+
},
24+
"errors": {
25+
"rate_limit_exceeded": {
26+
"name": "Rate Limit Exceeded"
27+
}
28+
},
29+
"actors": {
30+
"limiter": {}
31+
}
2932
}

modules/rate_limit/scripts/throttle.ts

Lines changed: 15 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { assert } from "https://deno.land/std@0.208.0/assert/mod.ts";
2+
import { ThrottleRequest, ThrottleResponse } from "../actors/limiter.ts";
13
import { RuntimeError, ScriptContext } from "../module.gen.ts";
24

35
export interface Request {
@@ -28,58 +30,23 @@ export async function run(
2830
ctx: ScriptContext,
2931
req: Request,
3032
): Promise<Response> {
31-
interface TokenBucket {
32-
tokens: number;
33-
lastRefill: Date;
34-
}
33+
assert(req.requests > 0);
34+
assert(req.period > 0);
35+
36+
// Create key
37+
const key = `${JSON.stringify(req.type)}.${JSON.stringify(req.key)}`;
3538

36-
// Update the token bucket
37-
//
38-
// `TokenBucket` is an unlogged table which are significantly faster to
39-
// write to than regular tables, but are not durable. This is important
40-
// because this script will be called on every request.
41-
const rows = await ctx.db.$queryRawUnsafe<TokenBucket[]>(
42-
`
43-
WITH
44-
"UpdateBucket" AS (
45-
UPDATE "${ctx.dbSchema}"."TokenBuckets" b
46-
SET
47-
"tokens" = CASE
48-
-- Reset the bucket and consume 1 token
49-
WHEN now() > b."lastRefill" + make_interval(secs => $4) THEN $3 - 1
50-
-- Consume 1 token
51-
ELSE b.tokens - 1
52-
END,
53-
"lastRefill" = CASE
54-
WHEN now() > b."lastRefill" + make_interval(secs => $4) THEN now()
55-
ELSE b."lastRefill"
56-
END
57-
WHERE b."type" = $1 AND b."key" = $2
58-
RETURNING b."tokens", b."lastRefill"
59-
),
60-
inserted AS (
61-
INSERT INTO "${ctx.dbSchema}"."TokenBuckets" ("type", "key", "tokens", "lastRefill")
62-
SELECT $1, $2, $3 - 1, now()
63-
WHERE NOT EXISTS (SELECT 1 FROM "UpdateBucket")
64-
RETURNING "tokens", "lastRefill"
65-
)
66-
SELECT * FROM "UpdateBucket"
67-
UNION ALL
68-
SELECT * FROM inserted;
69-
`,
70-
req.type,
71-
req.key,
72-
req.requests,
73-
req.period,
74-
);
75-
const { tokens, lastRefill } = rows[0];
39+
// Throttle request
40+
const res = await ctx.actors.limiter.getOrCreateAndCall<undefined, ThrottleRequest, ThrottleResponse>(key, undefined, "throttle", {
41+
requests: req.requests,
42+
period: req.period,
43+
});
7644

77-
// If the bucket is empty, throw an error
78-
if (tokens < 0) {
45+
// Check if allowed
46+
if (!res.success) {
7947
throw new RuntimeError("RATE_LIMIT_EXCEEDED", {
8048
meta: {
81-
retryAfter: new Date(lastRefill.getTime() + req.period * 1000)
82-
.toUTCString(),
49+
retryAfter: new Date(res.refillAt).toUTCString(),
8350
},
8451
});
8552
}

modules/rate_limit/scripts/throttle_public.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,6 @@ export async function run(
2020
ctx: ScriptContext,
2121
req: Request,
2222
): Promise<Response> {
23-
const requests = req.requests || 20;
24-
const period = req.period || 300;
25-
2623
// Find the IP address of the client
2724
let key: string | undefined;
2825
for (const entry of ctx.trace.entries) {
@@ -32,7 +29,8 @@ export async function run(
3229
}
3330
}
3431

35-
// If no IP address, this request is not coming from a client
32+
// If no IP address, this request is not coming from a client and should not
33+
// be throttled
3634
if (!key) {
3735
return {};
3836
}

0 commit comments

Comments
 (0)