local rate = tonumber(ARGV[1]) -- tokens per second
local cap = tonumber(ARGV[2])
local now = tonumber(ARGV[3]) -- milliseconds
local b = redis.call("HMGET", KEYS[1], "tokens", "ts")
local tokens = tonumber(b[1]) or cap
local ts = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call("HSET", KEYS[1], "tokens", tokens, "ts", now)
redis.call("PEXPIRE", KEYS[1], math.ceil(cap / rate * 1000))
return allowed
The script keeps each bucket in a Redis hash. The hash holds two values: the current number of tokens and the time of the last update. On each request, the script does three things:
- It adds tokens based on the time that passed since the last update. The count never goes above the bucket size.
- If a token is available, it takes one and returns 1 (allowed).
- If no token is available, it returns 0 (blocked).
The key gets a TTL (time to live: Redis deletes the key after this time). The TTL is the time a bucket needs to refill completely. After that, a missing key means the same thing as a full bucket, so deleting it loses nothing.
Why a script? Reading, computing and writing must happen as one atomic step. Atomic means no other command can run in the middle. If Node did this with separate calls, two requests at the same moment could read the same token count. Both would spend the same token. Redis runs a Lua script atomically on the server, and it needs only one round trip.
Clock caveat: if the app passes the current time in, every app instance needs synced clocks. Instead, the script can call redis.call('TIME') to use the Redis server's clock.
- A hash stores the token count and the last refill time.
- Tokens refill based on elapsed time, up to the bucket's capacity.
- The script takes a token only if one is available.
- Lua makes read, compute and write one atomic step in one round trip.
- Time from the app needs synced clocks, so you can use Redis TIME instead.