11import fp from 'fastify-plugin' ;
22import { FastifyInstance , FastifyRequest , FastifyReply } from 'fastify' ;
3+ // Importing ioredis does not open a connection — a client is only
4+ // constructed when REDIS_URL is configured (see createRateLimiter).
5+ import Redis from 'ioredis' ;
36
47// ─── Interfaces ──────────────────────────────────────────────────────────────
58
@@ -12,6 +15,8 @@ export interface RateLimitResult {
1215
1316export interface RateLimiter {
1417 check ( key : string , limit : number , windowSeconds : number ) : Promise < RateLimitResult > ;
18+ /** Clear all window state. Used by tests. */
19+ reset ( ) : void | Promise < void > ;
1520}
1621
1722// ─── In-Memory Implementation ────────────────────────────────────────────────
@@ -57,6 +62,72 @@ export class InMemoryRateLimiter implements RateLimiter {
5762 }
5863}
5964
65+ // ─── Redis Implementation ────────────────────────────────────────────────────
66+
67+ interface RedisMultiChain {
68+ incr ( key : string ) : RedisMultiChain ;
69+ expire ( key : string , seconds : number , nx : 'NX' ) : RedisMultiChain ;
70+ exec ( ) : Promise < unknown > ;
71+ }
72+
73+ export interface RedisLike {
74+ multi ( ) : RedisMultiChain ;
75+ ttl ( key : string ) : Promise < number > ;
76+ }
77+
78+ /**
79+ * Redis-backed fixed-window rate limiter for multi-instance deployments.
80+ * Enabled by setting REDIS_URL. Uses INCR + EXPIRE NX so all instances share
81+ * one window per key.
82+ *
83+ * Degrades rather than breaks: if Redis is unreachable, falls back to the
84+ * per-process in-memory limiter (weaker isolation, but requests keep flowing
85+ * and abuse is still bounded per instance) and logs the failure at most once
86+ * per minute.
87+ */
88+ export class RedisRateLimiter implements RateLimiter {
89+ private readonly fallback = new InMemoryRateLimiter ( ) ;
90+ private lastErrorLogAt = 0 ;
91+
92+ // Structural type covering the slice of ioredis the limiter uses; kept
93+ // loose so tests can supply an in-process fake without a Redis server.
94+ constructor ( private readonly redis : RedisLike ) { }
95+
96+ async check ( key : string , limit : number , windowSeconds : number ) : Promise < RateLimitResult > {
97+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
98+ const redisKey = `ratelimit:${ key } :${ Math . floor ( now / windowSeconds ) } ` ;
99+ try {
100+ const results = ( await this . redis
101+ . multi ( )
102+ . incr ( redisKey )
103+ // NX: only set the TTL when the key has none — first request in the
104+ // window owns the expiry; later requests must not extend it.
105+ . expire ( redisKey , windowSeconds , 'NX' )
106+ . exec ( ) ) as Array < [ Error | null , unknown ] > | null ;
107+ if ( ! results ) throw new Error ( 'redis MULTI aborted' ) ;
108+ const [ incrErr , count ] = results [ 0 ] ! ;
109+ if ( incrErr ) throw incrErr ;
110+ const used = Number ( count ) ;
111+ return {
112+ allowed : used <= limit ,
113+ limit,
114+ remaining : Math . max ( 0 , limit - used ) ,
115+ resetAt : ( Math . floor ( now / windowSeconds ) + 1 ) * windowSeconds ,
116+ } ;
117+ } catch ( error ) {
118+ if ( now - this . lastErrorLogAt >= 60 ) {
119+ this . lastErrorLogAt = now ;
120+ console . error ( 'Rate limiter: Redis unavailable, using in-memory fallback:' , error instanceof Error ? error . message : error ) ;
121+ }
122+ return this . fallback . check ( key , limit , windowSeconds ) ;
123+ }
124+ }
125+
126+ reset ( ) : void {
127+ this . fallback . reset ( ) ;
128+ }
129+ }
130+
60131// ─── Rate Limit Tiers ────────────────────────────────────────────────────────
61132
62133export interface RateLimitTier {
@@ -80,7 +151,22 @@ export function getEndpointCategory(method: string, url: string): 'evaluate' | '
80151
81152// ─── Fastify Plugin ──────────────────────────────────────────────────────────
82153
83- export const rateLimiter = new InMemoryRateLimiter ( ) ;
154+ function createRateLimiter ( ) : RateLimiter {
155+ const redisUrl = process . env [ 'REDIS_URL' ] ;
156+ if ( ! redisUrl ) return new InMemoryRateLimiter ( ) ;
157+ const client = new Redis ( redisUrl , {
158+ // Never let a slow Redis stall requests: short timeouts, no retry queue.
159+ connectTimeout : 2000 ,
160+ commandTimeout : 1000 ,
161+ maxRetriesPerRequest : 1 ,
162+ enableOfflineQueue : false ,
163+ lazyConnect : false ,
164+ } ) ;
165+ console . log ( 'Rate limiter: using Redis backend' ) ;
166+ return new RedisRateLimiter ( client ) ;
167+ }
168+
169+ export const rateLimiter = createRateLimiter ( ) ;
84170
85171async function rateLimitPluginImpl ( app : FastifyInstance ) {
86172 app . addHook ( 'onRequest' , async ( request : FastifyRequest , reply : FastifyReply ) => {
0 commit comments