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

Commit

Permalink
refactor(rate_limit): migrate to actors
Browse files Browse the repository at this point in the history
  • Loading branch information
NathanFlurry committed Jun 27, 2024
1 parent 28f260f commit e2f411b
Show file tree
Hide file tree
Showing 4 changed files with 93 additions and 79 deletions.
46 changes: 46 additions & 0 deletions modules/rate_limit/actors/limiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { ActorBase } from "../module.gen.ts";

type Input = undefined;

interface State {
tokens: number;
lastRefillTimestamp: number;
}

export interface ThrottleRequest {
requests: number;
period: number;
}

export interface ThrottleResponse {
success: boolean;
refillAt: number;
}

export class Actor extends ActorBase<undefined, State> {
public initialize(_input: Input): State {
// Will refill on first call of `throttle`
return {
tokens: 0,
lastRefillTimestamp: 0,
};
}

throttle(req: ThrottleRequest): ThrottleResponse {
// Reset bucket
const now = Date.now();
if (now > this.state.lastRefillTimestamp + req.period * 1000) {
this.state.tokens = req.requests;
this.state.lastRefillTimestamp = now;
}

// Attempt to consume token
const success = this.state.tokens >= 1;
if (success) {
this.state.tokens -= 1;
}

const refillAt = Math.ceil((1 - this.state.tokens) * (req.period / req.requests));
return { success, refillAt };
}
}
57 changes: 30 additions & 27 deletions modules/rate_limit/module.json
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
{
"name": "Rate Limit",
"description": "Prevent abuse by limiting request rate.",
"icon": "gauge-circle-minus",
"tags": [
"core",
"utility"
],
"authors": [
"rivet-gg",
"NathanFlurry"
],
"status": "stable",
"scripts": {
"throttle": {
"name": "Throttle",
"description": "Limit the amount of times an request can be made by a given key."
},
"throttle_public": {
"name": "Throttle Public",
"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."
}
},
"errors": {
"rate_limit_exceeded": {
"name": "Rate Limit Exceeded"
}
}
"name": "Rate Limit",
"description": "Prevent abuse by limiting request rate.",
"icon": "gauge-circle-minus",
"tags": [
"core",
"utility"
],
"authors": [
"rivet-gg",
"NathanFlurry"
],
"status": "stable",
"scripts": {
"throttle": {
"name": "Throttle",
"description": "Limit the amount of times an request can be made by a given key."
},
"throttle_public": {
"name": "Throttle Public",
"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."
}
},
"errors": {
"rate_limit_exceeded": {
"name": "Rate Limit Exceeded"
}
},
"actors": {
"limiter": {}
}
}
63 changes: 15 additions & 48 deletions modules/rate_limit/scripts/throttle.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { assert } from "https://deno.land/[email protected]/assert/mod.ts";
import { ThrottleRequest, ThrottleResponse } from "../actors/limiter.ts";
import { RuntimeError, ScriptContext } from "../module.gen.ts";

export interface Request {
Expand Down Expand Up @@ -28,58 +30,23 @@ export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
interface TokenBucket {
tokens: number;
lastRefill: Date;
}
assert(req.requests > 0);
assert(req.period > 0);

// Create key
const key = `${JSON.stringify(req.type)}.${JSON.stringify(req.key)}`;

// Update the token bucket
//
// `TokenBucket` is an unlogged table which are significantly faster to
// write to than regular tables, but are not durable. This is important
// because this script will be called on every request.
const rows = await ctx.db.$queryRawUnsafe<TokenBucket[]>(
`
WITH
"UpdateBucket" AS (
UPDATE "${ctx.dbSchema}"."TokenBuckets" b
SET
"tokens" = CASE
-- Reset the bucket and consume 1 token
WHEN now() > b."lastRefill" + make_interval(secs => $4) THEN $3 - 1
-- Consume 1 token
ELSE b.tokens - 1
END,
"lastRefill" = CASE
WHEN now() > b."lastRefill" + make_interval(secs => $4) THEN now()
ELSE b."lastRefill"
END
WHERE b."type" = $1 AND b."key" = $2
RETURNING b."tokens", b."lastRefill"
),
inserted AS (
INSERT INTO "${ctx.dbSchema}"."TokenBuckets" ("type", "key", "tokens", "lastRefill")
SELECT $1, $2, $3 - 1, now()
WHERE NOT EXISTS (SELECT 1 FROM "UpdateBucket")
RETURNING "tokens", "lastRefill"
)
SELECT * FROM "UpdateBucket"
UNION ALL
SELECT * FROM inserted;
`,
req.type,
req.key,
req.requests,
req.period,
);
const { tokens, lastRefill } = rows[0];
// Throttle request
const res = await ctx.actors.limiter.getOrCreateAndCall<undefined, ThrottleRequest, ThrottleResponse>(key, undefined, "throttle", {
requests: req.requests,
period: req.period,
});

// If the bucket is empty, throw an error
if (tokens < 0) {
// Check if allowed
if (!res.success) {
throw new RuntimeError("RATE_LIMIT_EXCEEDED", {
meta: {
retryAfter: new Date(lastRefill.getTime() + req.period * 1000)
.toUTCString(),
retryAfter: new Date(res.refillAt).toUTCString(),
},
});
}
Expand Down
6 changes: 2 additions & 4 deletions modules/rate_limit/scripts/throttle_public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ export async function run(
ctx: ScriptContext,
req: Request,
): Promise<Response> {
const requests = req.requests || 20;
const period = req.period || 300;

// Find the IP address of the client
let key: string | undefined;
for (const entry of ctx.trace.entries) {
Expand All @@ -32,7 +29,8 @@ export async function run(
}
}

// If no IP address, this request is not coming from a client
// If no IP address, this request is not coming from a client and should not
// be throttled
if (!key) {
return {};
}
Expand Down

0 comments on commit e2f411b

Please sign in to comment.