174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
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:<identifier>")
|
|
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<RateLimitResult> {
|
|
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;
|
|
}
|
|
}
|
|
}
|