UNPKG

oss-ratelimit

Version:

Flexible rate limiting library with Redis for TypeScript applications

169 lines (144 loc) 11.7 kB
import{createClient as b}from"redis";var R=o=>"refillRate"in o?"tokenBucket":"interval"in o?"slidingWindow":"fixedWindow",y=class{constructor(e=6e4){this.cache=new Map,this.ttl=e,setInterval(()=>this.cleanup(),Math.min(e,6e4))}get(e){let t=Date.now(),i=this.cache.get(e);return!i||i.expires<t?0:i.count}set(e,t,i){this.cache.set(e,{count:t,expires:Date.now()+Math.min(i,this.ttl)})}increment(e,t){let i=Date.now(),r=this.cache.get(e)||{count:0,expires:i+t};return r.expires<i?(r.count=1,r.expires=i+t):r.count++,this.cache.set(e,r),r.count}cleanup(){let e=Date.now();for(let[t,i]of this.cache.entries())i.expires<e&&this.cache.delete(t)}},p=class{constructor(e){this.redisPromise=Promise.resolve(e.redis),this.limiter=e.limiter,this.limiterType=R(e.limiter),this.prefix=e.prefix||"ratelimit",this.analytics=e.analytics??!1,this.timeout=e.timeout??1e3,e.ephemeralCache&&(this.ephemeralCache=new y(e.ephemeralCacheTTL))}async getRedis(){try{let e=new Promise((i,r)=>{setTimeout(()=>r(new a(`Redis connection timed out after ${this.timeout}ms`)),this.timeout)}),t=await Promise.race([this.redisPromise,e]);return(!t.isOpen||!await t.ping())&&await t.connect(),t}catch(e){throw new a(`Failed to connect to Redis: ${e instanceof Error?e.message:String(e)}`)}}async limit(e){let t=Date.now(),i=`${this.prefix}:${e}`;if(this.ephemeralCache&&this.limiterType==="slidingWindow"){let r=this.limiter.interval,l=this.limiter.limit;try{return await this.applySlidingWindowLimit(i,t)}catch(m){console.warn(`Redis error, using ephemeral cache: ${m instanceof Error?m.message:String(m)}`);let u=this.ephemeralCache.increment(i,r),s=u<=l;return{success:s,limit:l,remaining:Math.max(0,l-u),reset:t+r,retryAfter:s?0:Math.ceil(r/1e3)}}}try{switch(this.limiterType){case"slidingWindow":return await this.applySlidingWindowLimit(i,t);case"fixedWindow":return await this.applyFixedWindowLimit(i,t);case"tokenBucket":return await this.applyTokenBucketLimit(i,t);default:throw new a(`Unknown limiter type: ${this.limiterType}`)}}catch(r){console.error(`Rate limiting error: ${r instanceof Error?r.message:String(r)}`);let l="limit"in this.limiter?this.limiter.limit:10;return{success:!0,limit:l,remaining:l-1,reset:t+6e4}}}async applySlidingWindowLimit(e,t){let i=await this.getRedis(),{limit:r,interval:l}=this.limiter,m=t-l,u=` local key = KEYS[1] local analyticsKey = KEYS[2] local now = tonumber(ARGV[1]) local windowMs = tonumber(ARGV[2]) local maxRequests = tonumber(ARGV[3]) local doAnalytics = tonumber(ARGV[4]) local windowStart = now - windowMs -- Remove counts older than the current window redis.call('ZREMRANGEBYSCORE', key, 0, windowStart) -- Get current count local count = redis.call('ZCARD', key) local success = count < maxRequests -- Add current timestamp if successful if success then redis.call('ZADD', key, now, now .. ':' .. math.random()) count = count + 1 end -- Set expiration to keep memory usage bounded redis.call('PEXPIRE', key, windowMs * 2) -- Analytics if requested if doAnalytics == 1 then -- Record request timestamp for analytics redis.call('ZADD', analyticsKey, now, now) redis.call('PEXPIRE', analyticsKey, windowMs * 2) end -- Calculate when the oldest request expires local oldestTimestamp = 0 if count >= maxRequests then local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') if #oldest >= 2 then oldestTimestamp = tonumber(oldest[2]) end end -- Calculate pending and throughput if analytics enabled local pending = 0 local throughput = 0 if doAnalytics == 1 then pending = count -- Calculate requests in the last second local secondAgo = now - 1000 throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf') end -- Return results return { success and 1 or 0, maxRequests, math.max(0, maxRequests - count), now + windowMs, oldestTimestamp > 0 and math.ceil((oldestTimestamp + windowMs - now) / 1000) or 0, pending, throughput } `;try{let s=`${e}:analytics`,n=await i.eval(u,{keys:[e,s],arguments:[t.toString(),l.toString(),r.toString(),this.analytics?"1":"0"]});if(!Array.isArray(n))throw new a("Invalid response from Redis");let c={success:!!n[0],limit:Number(n[1]),remaining:Number(n[2]),reset:Number(n[3])},d=Number(n[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(n[5]),c.throughput=Number(n[6])),this.ephemeralCache&&this.ephemeralCache.set(e,r-c.remaining,l),c}catch(s){throw new a(`Sliding window limit error: ${s instanceof Error?s.message:String(s)}`)}}async applyFixedWindowLimit(e,t){let i=await this.getRedis(),{limit:r,interval:l}=this.limiter,m=`${e}:${Math.floor(t/l)}`,u=` local key = KEYS[1] local analyticsKey = KEYS[2] local limit = tonumber(ARGV[1]) local windowMs = tonumber(ARGV[2]) local doAnalytics = tonumber(ARGV[3]) local now = tonumber(ARGV[4]) -- Increment counter for this window local count = redis.call('INCR', key) -- Set expiration if this is first request in window if count == 1 then redis.call('PEXPIRE', key, windowMs * 2) end local success = count <= limit -- Analytics if doAnalytics == 1 then redis.call('ZADD', analyticsKey, now, now) redis.call('PEXPIRE', analyticsKey, windowMs) end -- Calculate remaining time in window local ttl = redis.call('PTTL', key) if ttl < 0 then ttl = windowMs end -- Calculate throughput if analytics enabled local throughput = 0 if doAnalytics == 1 then -- Calculate requests in the last second local secondAgo = now - 1000 throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf') end return { success and 1 or 0, limit, math.max(0, limit - count), now + ttl, success and 0 or math.ceil(ttl / 1000), count, throughput } `;try{let s=`${e}:analytics`,n=await i.eval(u,{keys:[m,s],arguments:[r.toString(),l.toString(),this.analytics?"1":"0",t.toString()]});if(!Array.isArray(n))throw new a("Invalid response from Redis");let c={success:!!n[0],limit:Number(n[1]),remaining:Number(n[2]),reset:Number(n[3])},d=Number(n[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(n[5]),c.throughput=Number(n[6])),c}catch(s){throw new a(`Fixed window limit error: ${s instanceof Error?s.message:String(s)}`)}}async applyTokenBucketLimit(e,t){let i=await this.getRedis(),{limit:r,refillRate:l,interval:m}=this.limiter,u=` local key = KEYS[1] local analyticsKey = KEYS[2] local now = tonumber(ARGV[1]) local refillRate = tonumber(ARGV[2]) local refillInterval = tonumber(ARGV[3]) local bucketCapacity = tonumber(ARGV[4]) local doAnalytics = tonumber(ARGV[5]) -- Get current bucket state local bucketInfo = redis.call('HMGET', key, 'tokens', 'lastRefill') local tokens = tonumber(bucketInfo[1]) or bucketCapacity local lastRefill = tonumber(bucketInfo[2]) or 0 -- Calculate token refill local elapsedTime = now - lastRefill local tokensToAdd = math.floor(elapsedTime * (refillRate / refillInterval)) if tokensToAdd > 0 then -- Add tokens based on elapsed time tokens = math.min(bucketCapacity, tokens + tokensToAdd) lastRefill = now end -- Try to consume a token local success = tokens >= 1 if success then tokens = tokens - 1 end -- Save updated bucket state redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill) redis.call('PEXPIRE', key, refillInterval * 2) -- Analytics if doAnalytics == 1 then redis.call('ZADD', analyticsKey, now, now) redis.call('PEXPIRE', analyticsKey, refillInterval) end -- Calculate time until next token refill local timeToNextToken = success and 0 or math.ceil((1 - tokens) * (refillInterval / refillRate)) -- Calculate throughput if analytics enabled local throughput = 0 if doAnalytics == 1 then -- Calculate requests in the last second local secondAgo = now - 1000 throughput = redis.call('ZCOUNT', analyticsKey, secondAgo, '+inf') end return { success and 1 or 0, bucketCapacity, tokens, now + (refillInterval / refillRate), timeToNextToken, bucketCapacity - tokens, throughput } `;try{let s=`${e}:analytics`,n=await i.eval(u,{keys:[e,s],arguments:[t.toString(),l.toString(),m.toString(),r.toString(),this.analytics?"1":"0"]});if(!Array.isArray(n))throw new a("Invalid response from Redis");let c={success:!!n[0],limit:Number(n[1]),remaining:Number(n[2]),reset:Number(n[3])},d=Number(n[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(n[5]),c.throughput=Number(n[6])),c}catch(s){throw new a(`Token bucket limit error: ${s instanceof Error?s.message:String(s)}`)}}async block(e,t=5e3){let i=Date.now(),r=0,l=100;for(;r<l;){r++;let m=await this.limit(e);if(m.success)return m;let u=Date.now();if(u-i>=t)throw new a(`Rate limit exceeded for ${e} after waiting ${u-i}ms`);let s=Math.max(50,Math.min(500,m.retryAfter?m.retryAfter*1e3/2:100));await new Promise(n=>setTimeout(n,s))}throw new a(`Rate limit exceeded for ${e} after ${l} attempts`)}async reset(e){try{let t=await this.getRedis(),i=`${this.prefix}:${e}`;await t.del(i),await t.del(`${i}:analytics`),this.ephemeralCache&&this.ephemeralCache.set(i,0,0)}catch(t){throw new a(`Failed to reset rate limit: ${t instanceof Error?t.message:String(t)}`)}}};var w=o=>{try{let[e,t]=o.split(" "),i=Number.parseInt(e||"s",10);if(Number.isNaN(i)||i<=0)throw new a(`Invalid time value: ${e}`);switch(t){case"ms":return i;case"s":return i*1e3;case"m":return i*60*1e3;case"h":return i*60*60*1e3;case"d":return i*24*60*60*1e3;default:throw new a(`Invalid time unit: ${t}`)}}catch(e){throw e instanceof a?e:new a(`Failed to parse time window: ${o}`)}};var a=class extends Error{constructor(e){super(e),this.name="RatelimitError"}},$=(o,e)=>({limit:o,interval:w(e)}),k=(o,e)=>({limit:o,interval:w(e)}),K=(o,e,t)=>({refillRate:o,interval:w(e),limit:t}),h,T=o=>h||(h=b({url:process.env[o]||"redis://localhost:6379",socket:{reconnectStrategy:e=>Math.min(e*50,1e3)}}),h.on("error",e=>console.error("Redis Client Error",e)),(async()=>{try{(!h.isOpen||!await h.ping())&&await h.connect()}catch(e){console.error("Failed to connect to Redis:",e)}})(),h),L=o=>{let e=T(o?.envRedisKey??"REDIS_URL");return A(e,o)},A=(o,e)=>new p({redis:o,limiter:e?.limiter??k(10,"10 s"),prefix:e?.prefix??"open-ratelimit",analytics:e?.analytics??!1,timeout:e?.timeout??1e3,ephemeralCache:e?.ephemeralCache??!0,ephemeralCacheTTL:e?.ephemeralCacheTTL??6e4});export{p as Ratelimit,a as RatelimitError,A as createRateLimiter,L as createSingletonRateLimiter,$ as fixedWindow,T as getRedisSingleClient,k as slidingWindow,K as tokenBucket}; //# sourceMappingURL=index.mjs.map