oss-ratelimit
Version:
Flexible rate limiting library with Redis for TypeScript applications
169 lines (144 loc) • 11.7 kB
JavaScript
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