import type { Cluster } from "ioredis"; import type Redis from "ioredis"; /** * Options for configuring the RateLimiter. */ export interface GCRARateLimiterOptions { /** An instance of ioredis. */ redis: Redis | Cluster; /** * A string prefix to namespace keys in Redis. * Defaults to "ratelimit:". */ keyPrefix?: string; /** * The minimum interval between requests (the emission interval) in milliseconds. * For example, 1000 ms for one request per second. */ emissionInterval: number; /** * The burst tolerance in milliseconds. This represents how much “credit” can be * accumulated to allow short bursts beyond the average rate. * For example, if you want to allow 3 requests in a burst with an emission interval of 1000 ms, * you might set this to 3000. */ burstTolerance: number; /** * Expiration for the Redis key in milliseconds. * Defaults to the larger of 60 seconds or (emissionInterval + burstTolerance). */ keyExpiration?: number; } /** * The result of a rate limit check. */ export interface RateLimitResult { /** Whether the request is allowed. */ allowed: boolean; /** * If not allowed, this is the number of milliseconds the caller should wait * before retrying. */ retryAfter?: number; } /** * A rate limiter using Redis and the Generic Cell Rate Algorithm (GCRA). * * The GCRA is implemented using a Lua script that runs atomically in Redis. * * When a request comes in, the algorithm: * - Retrieves the current "Theoretical Arrival Time" (TAT) from Redis (or initializes it if missing). * - If the current time is greater than or equal to the TAT, the request is allowed and the TAT is updated to now + emissionInterval. * - Otherwise, if the current time plus the burst tolerance is at least the TAT, the request is allowed and the TAT is incremented. * - If neither condition is met, the request is rejected and a Retry-After value is returned. */ export class GCRARateLimiter { private redis: Redis | Cluster; private keyPrefix: string; private emissionInterval: number; private burstTolerance: number; private keyExpiration: number; constructor(options: GCRARateLimiterOptions) { this.redis = options.redis; this.keyPrefix = options.keyPrefix || "gcra:ratelimit:"; this.emissionInterval = options.emissionInterval; this.burstTolerance = options.burstTolerance; // Default expiration: at least 60 seconds or the sum of emissionInterval and burstTolerance this.keyExpiration = options.keyExpiration || Math.max(60_000, this.emissionInterval + this.burstTolerance); // Define a custom Redis command 'gcra' that implements the GCRA algorithm. // Using defineCommand ensures the Lua script is loaded once and run atomically. this.redis.defineCommand("gcra", { numberOfKeys: 1, lua: ` --[[ GCRA Lua script KEYS[1] - The rate limit key (e.g. "ratelimit:") ARGV[1] - Current time in ms (number) ARGV[2] - Emission interval in ms (number) ARGV[3] - Burst tolerance in ms (number) ARGV[4] - Key expiration in ms (number) Returns: { allowedFlag, value } allowedFlag: 1 if allowed, 0 if rate-limited. value: 0 when allowed; if not allowed, the number of ms to wait. ]]-- local key = KEYS[1] local now = tonumber(ARGV[1]) local emission_interval = tonumber(ARGV[2]) local burst_tolerance = tonumber(ARGV[3]) local expire = tonumber(ARGV[4]) -- Get the stored Theoretical Arrival Time (TAT) or default to 0. local tat = tonumber(redis.call("GET", key) or 0) if tat == 0 then tat = now end local allowed, new_tat, retry_after if now >= tat then -- No delay: request is on schedule. new_tat = now + emission_interval allowed = true elseif (now + burst_tolerance) >= tat then -- Within burst capacity: allow request. new_tat = tat + emission_interval allowed = true else -- Request exceeds the allowed burst; calculate wait time. allowed = false retry_after = tat - (now + burst_tolerance) end if allowed then redis.call("SET", key, new_tat, "PX", expire) return {1, 0} else return {0, retry_after} end `, }); } /** * Checks whether a request associated with the given identifier is allowed. * * @param identifier A unique string identifying the subject of rate limiting (e.g. user ID, IP address, or domain). * @returns A promise that resolves to a RateLimitResult. * * @example * const result = await rateLimiter.check('user:12345'); * if (!result.allowed) { * // Tell the client to retry after result.retryAfter milliseconds. * } */ async check(identifier: string): Promise { const key = `${this.keyPrefix}${identifier}`; const now = Date.now(); try { // Call the custom 'gcra' command. // The script returns an array: [allowedFlag, value] // - allowedFlag: 1 if allowed; 0 if rejected. // - value: 0 when allowed; if rejected, the number of ms to wait before retrying. // @ts-expect-error: The custom command is defined via defineCommand. const result: [number, number] = await this.redis.gcra( key, now, this.emissionInterval, this.burstTolerance, this.keyExpiration ); const allowed = result[0] === 1; if (allowed) { return { allowed: true }; } else { return { allowed: false, retryAfter: result[1] }; } // eslint-disable-next-line no-useless-catch -- catch documents the fail-open/fail-closed policy } catch (error) { // In a production system you might log the error and either // allow the request (fail open) or deny it (fail closed). // Here we choose to propagate the error. throw error; } } }