From 6cbaed715d3a2baa11f9f545e28a895cb9965327 Mon Sep 17 00:00:00 2001 From: pilcrowOnPaper Date: Sat, 5 Oct 2024 16:38:30 +0900 Subject: [PATCH] add redis throttler guide --- pages/rate-limit/throttling.md | 88 ++++++++++++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 3 deletions(-) diff --git a/pages/rate-limit/throttling.md b/pages/rate-limit/throttling.md index 4999a1c..2415a76 100644 --- a/pages/rate-limit/throttling.md +++ b/pages/rate-limit/throttling.md @@ -8,6 +8,8 @@ After each failed attempt, the user has to wait longer before their next attempt ## Memory storage +`timeoutSeconds` holds the number of seconds to lock out the user for. + ```ts export class Throttler<_Key> { public timeoutSeconds: number[]; @@ -50,15 +52,95 @@ interface ThrottlingCounter { } ``` -Here, on each failed sign in attempt, the lockout gets extended with a max of 5 minutes. +Here, on each failed sign in attempt, the lockout time gets extended with a max of 5 minutes. + +```ts +const throttler = new Throttler([1, 2, 4, 8, 16, 30, 60, 180, 300]); + +if (!throttler.consume(userId)) { + throw new Error("Too many requests"); +} +const validPassword = verifyPassword(password); +if (!validPassword) { + throw new Error("Invalid password"); +} +throttler.reset(user.id); +``` + +## Redis + +We'll use Lua scripts to ensure queries are atomic. `timeoutSeconds` holds the number of seconds to lock out the user for. + +```lua +-- Returns 1 if allowed, 0 if not +local key = KEYS[1] +local now = tonumber(ARGV[1]) + +local timeoutSeconds = {1, 2, 4, 8, 16, 30, 60, 180, 300} + +local fields = redis.call("HGETALL", key) +if #fields == 0 then + redis.call("HSET", key, "index", 1, "updated_at", now) + return {1} +end +local index = 0 +local updatedAt = 0 +for i = 1, #fields, 2 do + if fields[i] == "index" then + index = tonumber(fields[i+1]) + elseif fields[i] == "updated_at" then + updatedAt = tonumber(fields[i+1]) + end +end +local allowed = now - updatedAt >= timeoutSeconds[index] +if not allowed then + return {0} +end +index = math.min(index + 1, #timeoutSeconds) +redis.call("HSET", key, "index", index, "updated_at", now) +return {1} +``` + +Load the script and retrieve the script hash. + +```ts +const SCRIPT_SHA = await client.scriptLoad(script); +``` + +Reference the script with the hash. + +```ts +export class Throttler { + private storageKey: string; + + constructor(storageKey: string) { + this.storageKey = storageKey; + } + + public async consume(key: string): Promise { + const result = await client.EVALSHA(SCRIPT_SHA, { + keys: [`${this.storageKey}:${key}`], + arguments: [Math.floor(Date.now() / 1000).toString()] + }); + return Boolean(result[0]); + } + + public async reset(key: string): Promise { + await client.DEL(key); + } +} +``` + +Here, on each failed sign in attempt, the lockout time gets extended. ```ts -const throttler = new Throttler([0, 1, 2, 4, 8, 16, 30, 60, 180, 300]); +const throttler = new Throttler("login_throttler"); if (!throttler.consume(userId)) { throw new Error("Too many requests"); } -if (!verifyPassword(password)) { +const validPassword = verifyPassword(password); +if (!validPassword) { throw new Error("Invalid password"); } throttler.reset(user.id);