Lucia

Token bucket

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.

Memory storage

This requires the server to persist its memory across requests and will not work in serverless environments.

export class TokenBucketRateLimit<_Key> {
	public max: number;
	public refillIntervalSeconds: number;

	constructor(max: number, refillIntervalSeconds: number) {
		this.max = max;
		this.refillIntervalSeconds = refillIntervalSeconds;
	}

	private storage = new Map<_Key, Bucket>();

	public consume(key: _Key, cost: number): boolean {
		let bucket = this.storage.get(key) ?? null;
		const now = Date.now();
		if (bucket === null) {
			bucket = {
				count: this.max - cost,
				refilledAt: now
			};
			this.storage.set(key, bucket);
			return true;
		}
		const refill = Math.floor((now - bucket.refilledAt) / (this.refillIntervalSeconds * 1000));
		bucket.count = Math.min(bucket.count + refill, this.max);
		bucket.refilledAt = bucket.refilledAt + refill * this.refillIntervalSeconds;
		if (bucket.count < cost) {
			return false;
		}
		bucket.count -= cost;
		this.storage.set(key, bucket);
		return true;
	}
}

interface Bucket {
	count: number;
	refilledAt: number;
}
// Bucket that has 10 tokens max and refills at a rate of 2 tokens/sec
const ratelimit = new TokenBucketRateLimit<string>(10, 2);

if (!ratelimit.consume(ip, 1)) {
	throw new Error("Too many requests");
}

Redis

We'll use Lua scripts to ensure queries are atomic.

-- Returns 1 if allowed, 0 if not
local key                   = KEYS[1]
local max                   = tonumber(ARGV[1])
local refillIntervalSeconds = tonumber(ARGV[2])
local cost                  = tonumber(ARGV[3])
local now                   = tonumber(ARGV[4]) -- Current unix time in seconds

local fields = redis.call("HGETALL", key)

if #fields == 0 then
	local expiresInSeconds = cost * refillIntervalSeconds
	redis.call("HSET", key, "count", max - cost, "refilled_at", now)
	redis.call("EXPIRE", key, expiresInSeconds)
	return {1}
end

local count = 0
local refilledAt = 0
for i = 1, #fields, 2 do
	if fields[i] == "count" then
		count = tonumber(fields[i+1])
	elseif fields[i] == "refilled_at" then
		refilledAt = tonumber(fields[i+1])
	end
end

local refill = math.floor((now - refilledAt) / refillIntervalSeconds)
count = math.min(count + refill, max)
refilledAt = refilledAt + refill * refillIntervalSeconds

if count < cost then
	return {0}
end

count = count - cost
local expiresInSeconds = (max - count) * refillIntervalSeconds
redis.call("HSET", key, "count", count, "refilled_at", now)
redis.call("EXPIRE", key, expiresInSeconds)
return {1}

Load the script and retrieve the script hash.

const SCRIPT_SHA = await client.scriptLoad(script);

Reference the script with the hash.

export class TokenBucketRateLimit {
	private storageKey: string;

	public max: number;
	public refillIntervalSeconds: number;

	constructor(storageKey: string, max: number, refillIntervalSeconds: number) {
		this.storageKey = storageKey;
		this.max = max;
		this.refillIntervalSeconds = refillIntervalSeconds;
	}

	public async consume(key: string, cost: number): Promise<boolean> {
		const result = await client.EVALSHA(SCRIPT_SHA, {
			keys: [`${this.storageKey}:${key}`],
			arguments: [
				this.max.toString(),
				this.refillIntervalSeconds.toString(),
				cost.toString(),
				Math.floor(Date.now() / 1000).toString()
			]
		});
		return Boolean(result[0]);
	}
}
// Bucket that has 10 tokens max and refills at a rate of 2 tokens/sec.
// Ensure that the storage key is unique.
const ratelimit = new TokenBucketRateLimit("global_ip", 10, 2);

const valid = await ratelimit.consume(ip, 1);
if (!valid) {
	throw new Error("Too many requests");
}