|
| 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