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.

Application clocks versus the store clock as the time source When each pod passes its own clock into the script the stored schedule wobbles by the spread between pods; when the script reads the store's own clock every pod agrees and the schedule is stable. Whose clock writes the schedule? application clocks pod A: +0 ms, pod B: +400 ms TAT jumps forward and back effective limit wobbles bugs appear only after a resume store clock via TIME every pod reads one clock TAT moves monotonically limit stable across the fleet cost: none β€” TIME is local to the node the script is already running on the store; use the clock that is right there

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

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

typescript
// 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 };
  }
}
Control flow inside the script The script reads the store clock and the stored schedule, compares now against the schedule minus tolerance, returns immediately on denial without writing, and otherwise advances and stores the schedule with a tolerance-sized expiry. One atomic pass, two exits read TIME store clock GET schedule default: now now ≥ TAT − tau ? the whole decision no: return exact wait, write nothing yes: SET TAT = max(now, TAT) + T x cost, PX tau + T no read-modify-write race: the comparison and the write are the same atomic evaluation

Gotchas and edge cases

  • SET without PX leaks 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 T of zero. Math.round(periodMs / limit) returns 0 for a limit above one per millisecond, and the schedule then never advances. Clamp with Math.max(1, …).
  • redis.call('TIME') in a replica context. In older Redis versions TIME was non-deterministic and forbidden in replicated scripts; on modern Redis with effect replication this is fine, but if you target an old deployment, use redis.replicate_commands() first.
  • Cross-slot keys in a cluster. gcra:acct_42:search and gcra:acct_42:reports can 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.
  • commandTimeout missing. Without one, a stalled store turns into unbounded request latency; the limiter becomes the outage.
  • Reusing the key for a different limit. Changing T or Ο„ for an existing key reinterprets the stored schedule. Version the key prefix when you change the numbers materially.
Effect of the key expiry on a client's burst allowance An expiry shorter than the tolerance deletes the schedule while the client is still ahead of it, silently restoring a full burst; an expiry of tolerance plus one interval removes the key only once the burst would have been regained anyway. The expiry is part of the algorithm PX shorter than tau key vanishes mid-schedule client silently regains a burst load tests stop reproducing PX = tau + T x cost key lives exactly as long as the state still matters idle keys evict themselves memory is reclaimed either way; only one of the two keeps the limit honest

Verification and testing

javascript
// 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.