oss-ratelimit
Version:
Flexible rate limiting library with Redis for TypeScript applications
169 lines (144 loc) • 12.3 kB
JavaScript
;var y=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var S=(r,e)=>{for(var t in e)y(r,t,{get:e[t],enumerable:!0})},v=(r,e,t,i)=>{if(e&&typeof e=="object"||typeof e=="function")for(let n of x(e))!C.call(r,n)&&n!==t&&y(r,n,{get:()=>e[n],enumerable:!(i=A(e,n))||i.enumerable});return r};var E=r=>v(y({},"__esModule",{value:!0}),r);var $={};S($,{Ratelimit:()=>p,RatelimitError:()=>a,createRateLimiter:()=>T,createSingletonRateLimiter:()=>I,fixedWindow:()=>W,getRedisSingleClient:()=>k,slidingWindow:()=>b,tokenBucket:()=>N});module.exports=E($);var R=require("redis");var P=r=>"refillRate"in r?"tokenBucket":"interval"in r?"slidingWindow":"fixedWindow",f=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(),n=this.cache.get(e)||{count:0,expires:i+t};return n.expires<i?(n.count=1,n.expires=i+t):n.count++,this.cache.set(e,n),n.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=P(e.limiter),this.prefix=e.prefix||"ratelimit",this.analytics=e.analytics??!1,this.timeout=e.timeout??1e3,e.ephemeralCache&&(this.ephemeralCache=new f(e.ephemeralCacheTTL))}async getRedis(){try{let e=new Promise((i,n)=>{setTimeout(()=>n(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 n=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,n),o=u<=l;return{success:o,limit:l,remaining:Math.max(0,l-u),reset:t+n,retryAfter:o?0:Math.ceil(n/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(n){console.error(`Rate limiting error: ${n instanceof Error?n.message:String(n)}`);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:n,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 o=`${e}:analytics`,s=await i.eval(u,{keys:[e,o],arguments:[t.toString(),l.toString(),n.toString(),this.analytics?"1":"0"]});if(!Array.isArray(s))throw new a("Invalid response from Redis");let c={success:!!s[0],limit:Number(s[1]),remaining:Number(s[2]),reset:Number(s[3])},d=Number(s[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(s[5]),c.throughput=Number(s[6])),this.ephemeralCache&&this.ephemeralCache.set(e,n-c.remaining,l),c}catch(o){throw new a(`Sliding window limit error: ${o instanceof Error?o.message:String(o)}`)}}async applyFixedWindowLimit(e,t){let i=await this.getRedis(),{limit:n,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 o=`${e}:analytics`,s=await i.eval(u,{keys:[m,o],arguments:[n.toString(),l.toString(),this.analytics?"1":"0",t.toString()]});if(!Array.isArray(s))throw new a("Invalid response from Redis");let c={success:!!s[0],limit:Number(s[1]),remaining:Number(s[2]),reset:Number(s[3])},d=Number(s[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(s[5]),c.throughput=Number(s[6])),c}catch(o){throw new a(`Fixed window limit error: ${o instanceof Error?o.message:String(o)}`)}}async applyTokenBucketLimit(e,t){let i=await this.getRedis(),{limit:n,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 o=`${e}:analytics`,s=await i.eval(u,{keys:[e,o],arguments:[t.toString(),l.toString(),m.toString(),n.toString(),this.analytics?"1":"0"]});if(!Array.isArray(s))throw new a("Invalid response from Redis");let c={success:!!s[0],limit:Number(s[1]),remaining:Number(s[2]),reset:Number(s[3])},d=Number(s[4]);return d>0&&(c.retryAfter=d),this.analytics&&(c.pending=Number(s[5]),c.throughput=Number(s[6])),c}catch(o){throw new a(`Token bucket limit error: ${o instanceof Error?o.message:String(o)}`)}}async block(e,t=5e3){let i=Date.now(),n=0,l=100;for(;n<l;){n++;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 o=Math.max(50,Math.min(500,m.retryAfter?m.retryAfter*1e3/2:100));await new Promise(s=>setTimeout(s,o))}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=r=>{try{let[e,t]=r.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: ${r}`)}};var a=class extends Error{constructor(e){super(e),this.name="RatelimitError"}},W=(r,e)=>({limit:r,interval:w(e)}),b=(r,e)=>({limit:r,interval:w(e)}),N=(r,e,t)=>({refillRate:r,interval:w(e),limit:t}),h,k=r=>h||(h=(0,R.createClient)({url:process.env[r]||"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),I=r=>{let e=k(r?.envRedisKey??"REDIS_URL");return T(e,r)},T=(r,e)=>new p({redis:r,limiter:e?.limiter??b(10,"10 s"),prefix:e?.prefix??"open-ratelimit",analytics:e?.analytics??!1,timeout:e?.timeout??1e3,ephemeralCache:e?.ephemeralCache??!0,ephemeralCacheTTL:e?.ephemeralCacheTTL??6e4});0&&(module.exports={Ratelimit,RatelimitError,createRateLimiter,createSingletonRateLimiter,fixedWindow,getRedisSingleClient,slidingWindow,tokenBucket});
//# sourceMappingURL=index.js.map