Implementing GCRA in Redis Lua
The whole algorithm is fifteen lines of Lua, and four of them are where implementations go wrong. This page walks through a production script β clock source, integer arithmetic, the denial path, header derivation, and cluster placement β as the concrete counterpart to the mechanics in the GCRA and virtual scheduling guide.
The problem in concrete numbers
You run 12 application pods across two availability zones. Each pod computes now from its own clock, and NTP keeps them within about 40 ms of each other on a good day, 400 ms after a virtual machine resumes from a snapshot. With GCRA the stored state is a timestamp, so a pod whose clock runs 400 ms fast writes a theoretical arrival time 400 ms into the future for every request it handles β and every other pod then sees the client as 400 ms further ahead of schedule than it really is. At an emission interval of 100 ms, that is four requests of error per skewed pod.
Reading the clock inside the script removes the entire class of problem: redis.call('TIME') returns the storeβs clock, which is the same clock for every caller.
The four lines that matter
| Line | Wrong version | Correct version | Failure it causes |
|---|---|---|---|
| Clock source | now passed from the caller |
redis.call('TIME') |
Fleet-wide schedule wobble under clock skew |
| Time unit | seconds | integer milliseconds | Rates above 1 per second become impossible |
| Denial path | write TAT, then check | check, return before writing | Retry loops extend their own lockout |
| Key expiry | EXPIRE with a fixed value |
PX of Ο + TΓcost |
Burst allowance resets during quiet periods |
Step-by-step implementation
-- gcra.lua β production GCRA. KEYS[1] = the identity's schedule key.
-- ARGV[1] = emission interval T in ms (integer)
-- ARGV[2] = tolerance tau in ms (integer)
-- ARGV[3] = cost multiplier (>= 1)
-- Returns { allowed, remaining, retry_after_ms, reset_after_ms }
local T = tonumber(ARGV[1])
local tau = tonumber(ARGV[2])
local cost = tonumber(ARGV[3]) or 1
-- (1) One clock for the whole fleet: the store's own.
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
-- An unseen key behaves as a client that is perfectly on schedule right now.
local tat = tonumber(redis.call('GET', KEYS[1])) or now
local increase = T * cost
local allow_at = tat - tau
-- (2) Denial returns FIRST: the schedule is never advanced by a rejected request,
-- so a client retrying in a tight loop cannot push its own recovery away.
if now < allow_at then
local remaining = math.floor((tau - (tat - now)) / T)
return { 0, math.max(0, remaining), allow_at - now, tat - now }
end
local new_tat = math.max(now, tat) + increase
-- (3) TTL covers the whole tolerance window, so an idle key expires only once the
-- client would have regained its full burst anyway.
redis.call('SET', KEYS[1], new_tat, 'PX', tau + increase)
-- (4) Remaining is derived from the same state that produced the verdict.
local remaining = math.floor((tau - (new_tat - now)) / T)
return { 1, math.max(0, remaining), 0, new_tat - now }The application wrapper keeps the arithmetic in one place and turns the four returned numbers into a response:
// gcra.ts β load once, call by SHA, degrade explicitly.
import Redis from "ioredis";
import { readFileSync } from "node:fs";
const redis = new Redis(process.env.REDIS_URL!, { commandTimeout: 50 });
const SRC = readFileSync(new URL("./gcra.lua", import.meta.url), "utf8");
let sha: string | null = null;
export interface Limit { limit: number; periodMs: number; burst: number; }
export interface Verdict { allowed: boolean; remaining: number; retryAfterMs: number; resetMs: number; }
async function evalGcra(key: string, args: (string | number)[]): Promise<number[]> {
if (!sha) sha = await redis.script("LOAD", SRC) as string;
try {
return await redis.evalsha(sha, 1, key, ...args) as number[];
} catch (err: any) {
if (String(err.message).includes("NOSCRIPT")) { // failover flushed the cache
sha = await redis.script("LOAD", SRC) as string;
return await redis.evalsha(sha, 1, key, ...args) as number[];
}
throw err;
}
}
export async function check(identity: string, route: string, l: Limit, cost = 1): Promise<Verdict> {
const T = Math.max(1, Math.round(l.periodMs / l.limit)); // integer ms, never zero
const tau = Math.round(l.burst * T);
// Hash tag keeps every key for this identity in one cluster slot.
const key = `gcra:{${identity}}:${route}`;
try {
const [allowed, remaining, retryAfterMs, resetMs] = await evalGcra(key, [T, tau, cost]);
return { allowed: allowed === 1, remaining, retryAfterMs, resetMs };
} catch {
// Explicit degradation: read paths fail open, and the caller records the metric.
return { allowed: true, remaining: 0, retryAfterMs: 0, resetMs: 0 };
}
}Gotchas and edge cases
SETwithoutPXleaks keys forever. A one-integer key is cheap, but tens of millions of them are not. The expiry must be at leastΟ + T Γ cost.- Integer
Tof zero.Math.round(periodMs / limit)returns0for a limit above one per millisecond, and the schedule then never advances. Clamp withMath.max(1, β¦). redis.call('TIME')in a replica context. In older Redis versionsTIMEwas non-deterministic and forbidden in replicated scripts; on modern Redis with effect replication this is fine, but if you target an old deployment, useredis.replicate_commands()first.- Cross-slot keys in a cluster.
gcra:acct_42:searchandgcra:acct_42:reportscan hash to different slots, which is fine for separate limits, but any script touching both at once will be rejected. Keep one key per invocation. - NOSCRIPT after failover. A promoted replica has an empty script cache. Catch the error and reload rather than failing the request.
commandTimeoutmissing. Without one, a stalled store turns into unbounded request latency; the limiter becomes the outage.- Reusing the key for a different limit. Changing
TorΟfor an existing key reinterprets the stored schedule. Version the key prefix when you change the numbers materially.
Verification and testing
// gcra.test.js β the four invariants worth pinning.
import { describe, it, expect, beforeEach } from "vitest";
import { check } from "./gcra.js";
const L = { limit: 10, periodMs: 1000, burst: 5 }; // T = 100 ms, tau = 500 ms
let id;
beforeEach(() => { id = `t_${crypto.randomUUID()}`; });
it("admits the burst from idle, then denies", async () => {
const v = [];
for (let i = 0; i < 7; i += 1) v.push(await check(id, "r", L));
expect(v.filter((x) => x.allowed).length).toBe(6); // burst 5 + the on-schedule one
expect(v[6].allowed).toBe(false);
});
it("reports a wait that actually works", async () => {
for (let i = 0; i < 7; i += 1) await check(id, "r", L);
const denied = await check(id, "r", L);
await new Promise((r) => setTimeout(r, denied.retryAfterMs + 5));
expect((await check(id, "r", L)).allowed).toBe(true);
});
it("does not extend the wait when hammered", async () => {
for (let i = 0; i < 7; i += 1) await check(id, "r", L);
const first = (await check(id, "r", L)).retryAfterMs;
for (let i = 0; i < 20; i += 1) await check(id, "r", L); // tight retry loop
const later = (await check(id, "r", L)).retryAfterMs;
expect(later).toBeLessThanOrEqual(first); // fails if TAT advanced on deny
});
it("expires the key once the tolerance has passed", async () => {
await check(id, "r", L);
const ttl = await redis.pttl(`gcra:{${id}}:r`);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(600); // tau + T
});The third test is the one that catches the classic bug. Run it against any GCRA implementation you did not write β including library implementations β before trusting it in production.
Frequently Asked Questions
Why read the clock inside the script instead of passing it in?
Because the stored state is a timestamp, so a caller's clock error is written directly into the schedule and then read by every other caller. Reading TIME inside the script makes the store the single clock for the whole fleet, at no extra cost since the script already runs there.
What TTL should the key have?
At least the tolerance plus one emission interval. Shorter and an idle client's schedule resets early, which quietly grants an extra burst and makes tests non-reproducible; much longer and you retain state for keys that will never return.
How do I return X-RateLimit-Remaining from GCRA?
Derive it in the same script: the number of whole emission intervals of slack left, floor((tau β (TAT β now)) / T), clamped at zero. Computing it from a second call produces a value that disagrees with the verdict as soon as traffic is concurrent.
Does this work on Redis Cluster?
Yes, provided each invocation touches one key and that key hashes consistently for the identity. Use a hash tag around the identity so all of a client's limit keys share a slot, and never write a script that reads two identities' keys in one call.
Related
- GCRA & Virtual Scheduling β the parent guide with the algorithm and its parameters.
- GCRA vs Token Bucket Precision β why the exact wait is worth the different mental model.
- Redis Lua vs INCR for Rate Limiting β why the whole decision belongs in one script.
- Handling Clock Skew in Distributed Limiters β the wider clock problem this script sidesteps.