@upstash/ratelimit
Advanced tools
@@ -64,3 +64,3 @@ /** | ||
}; | ||
type Algorithm<TContext> = (ctx: TContext, identifier: string, opts?: { | ||
type Algorithm<TContext> = (ctx: TContext, identifier: string, rate?: number, opts?: { | ||
cache?: EphemeralCache; | ||
@@ -73,2 +73,5 @@ }) => Promise<RatelimitResponse>; | ||
sadd: <TData>(key: string, ...members: TData[]) => Promise<number>; | ||
hset: <TValue>(key: string, obj: { | ||
[key: string]: TValue; | ||
}) => Promise<number>; | ||
eval: <TArgs extends unknown[], TData = unknown>(...args: [script: string, keys: string[], args: TArgs]) => Promise<TData>; | ||
@@ -214,5 +217,23 @@ } | ||
* ``` | ||
* | ||
* @param req.rate - The rate at which tokens will be added or consumed from the token bucket. A higher rate allows for more requests to be processed. Defaults to 1 token per interval if not specified. | ||
* | ||
* Usage with `req.rate` | ||
* @example | ||
* ```ts | ||
* const ratelimit = new Ratelimit({ | ||
* redis: Redis.fromEnv(), | ||
* limiter: Ratelimit.slidingWindow(100, "10 s") | ||
* }) | ||
* | ||
* const { success } = await ratelimit.limit(id, {rate: 10}) | ||
* if (!success){ | ||
* return "Nope" | ||
* } | ||
* return "Yes" | ||
* ``` | ||
*/ | ||
limit: (identifier: string, req?: { | ||
geo?: Geo; | ||
rate?: number; | ||
}) => Promise<RatelimitResponse>; | ||
@@ -291,3 +312,3 @@ /** | ||
* | ||
* @default true | ||
* @default false | ||
*/ | ||
@@ -419,3 +440,3 @@ analytics?: boolean; | ||
* | ||
* @default true | ||
* @default false | ||
*/ | ||
@@ -422,0 +443,0 @@ analytics?: boolean; |
@@ -133,3 +133,3 @@ "use strict"; | ||
} | ||
const time = parseInt(match[1]); | ||
const time = Number.parseInt(match[1]); | ||
const unit = match[2]; | ||
@@ -152,2 +152,55 @@ switch (unit) { | ||
// src/lua-scripts/multi.ts | ||
var fixedWindowScript = ` | ||
local key = KEYS[1] | ||
local id = ARGV[1] | ||
local window = ARGV[2] | ||
local incrementBy = tonumber(ARGV[3]) | ||
redis.call("HSET", key, id, incrementBy) | ||
local fields = redis.call("HGETALL", key) | ||
if #fields == 1 and tonumber(fields[1])==incrementBy then | ||
-- The first time this key is set, and the value will be equal to incrementBy. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", key, window) | ||
end | ||
return fields | ||
`; | ||
var slidingWindowScript = ` | ||
local currentKey = KEYS[1] -- identifier including prefixes | ||
local previousKey = KEYS[2] -- key of the previous bucket | ||
local tokens = tonumber(ARGV[1]) -- tokens per window | ||
local now = ARGV[2] -- current timestamp in milliseconds | ||
local window = ARGV[3] -- interval in milliseconds | ||
local requestId = ARGV[4] -- uuid for this request | ||
local incrementBy = tonumber(ARGV[5]) -- custom rate, default is 1 | ||
local currentFields = redis.call("HGETALL", currentKey) | ||
local requestsInCurrentWindow = 0 | ||
for i = 2, #currentFields, 2 do | ||
requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i]) | ||
end | ||
local previousFields = redis.call("HGETALL", previousKey) | ||
local requestsInPreviousWindow = 0 | ||
for i = 2, #previousFields, 2 do | ||
requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i]) | ||
end | ||
local percentageInCurrent = ( now % window) / window | ||
if requestsInPreviousWindow * (1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then | ||
return {currentFields, previousFields, false} | ||
end | ||
redis.call("HSET", currentKey, requestId, incrementBy) | ||
if requestsInCurrentWindow == 0 then | ||
-- The first time this key is set, the value will be equal to incrementBy. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second | ||
end | ||
return {currentFields, previousFields, true} | ||
`; | ||
// src/ratelimit.ts | ||
@@ -193,2 +246,19 @@ var Ratelimit = class { | ||
* ``` | ||
* | ||
* @param req.rate - The rate at which tokens will be added or consumed from the token bucket. A higher rate allows for more requests to be processed. Defaults to 1 token per interval if not specified. | ||
* | ||
* Usage with `req.rate` | ||
* @example | ||
* ```ts | ||
* const ratelimit = new Ratelimit({ | ||
* redis: Redis.fromEnv(), | ||
* limiter: Ratelimit.slidingWindow(100, "10 s") | ||
* }) | ||
* | ||
* const { success } = await ratelimit.limit(id, {rate: 10}) | ||
* if (!success){ | ||
* return "Nope" | ||
* } | ||
* return "Yes" | ||
* ``` | ||
*/ | ||
@@ -199,3 +269,3 @@ limit = async (identifier, req) => { | ||
try { | ||
const arr = [this.limiter(this.ctx, key)]; | ||
const arr = [this.limiter(this.ctx, key, req?.rate)]; | ||
if (this.timeout > 0) { | ||
@@ -332,18 +402,3 @@ arr.push( | ||
const windowDuration = ms(window); | ||
const script = ` | ||
local key = KEYS[1] | ||
local id = ARGV[1] | ||
local window = ARGV[2] | ||
redis.call("SADD", key, id) | ||
local members = redis.call("SMEMBERS", key) | ||
if #members == 1 then | ||
-- The first time this key is set, the value will be 1. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", key, window) | ||
end | ||
return members | ||
`; | ||
return async function(ctx, identifier) { | ||
return async (ctx, identifier, rate) => { | ||
if (ctx.cache) { | ||
@@ -364,22 +419,56 @@ const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier); | ||
const key = [identifier, bucket].join(":"); | ||
const incrementBy = rate ? Math.max(1, rate) : 1; | ||
const dbs = ctx.redis.map((redis) => ({ | ||
redis, | ||
request: redis.eval(script, [key], [requestId, windowDuration]) | ||
request: redis.eval( | ||
fixedWindowScript, | ||
[key], | ||
[requestId, windowDuration, incrementBy] | ||
) | ||
})); | ||
const firstResponse = await Promise.any(dbs.map((s) => s.request)); | ||
const usedTokens = firstResponse.length; | ||
const remaining = tokens - usedTokens - 1; | ||
const usedTokens = firstResponse.reduce((accTokens, usedToken, index) => { | ||
let parsedToken = 0; | ||
if (index % 2) { | ||
parsedToken = Number.parseInt(usedToken); | ||
} | ||
return accTokens + parsedToken; | ||
}, 0); | ||
const remaining = tokens - usedTokens; | ||
async function sync() { | ||
const individualIDs = await Promise.all(dbs.map((s) => s.request)); | ||
const allIDs = Array.from(new Set(individualIDs.flatMap((_) => _)).values()); | ||
const allIDs = Array.from( | ||
new Set( | ||
individualIDs.flatMap((_) => _).reduce((acc, curr, index) => { | ||
if (index % 2 === 0) { | ||
acc.push(curr); | ||
} | ||
return acc; | ||
}, []) | ||
).values() | ||
); | ||
for (const db of dbs) { | ||
const ids = await db.request; | ||
if (ids.length >= tokens) { | ||
const usedDbTokens = (await db.request).reduce((accTokens, usedToken, index) => { | ||
let parsedToken = 0; | ||
if (index % 2) { | ||
parsedToken = Number.parseInt(usedToken); | ||
} | ||
return accTokens + parsedToken; | ||
}, 0); | ||
const dbIds = (await db.request).reduce((ids, currentId, index) => { | ||
if (index % 2 === 0) { | ||
ids.push(currentId); | ||
} | ||
return ids; | ||
}, []); | ||
if (usedDbTokens >= tokens) { | ||
continue; | ||
} | ||
const diff = allIDs.filter((id) => !ids.includes(id)); | ||
const diff = allIDs.filter((id) => !dbIds.includes(id)); | ||
if (diff.length === 0) { | ||
continue; | ||
} | ||
await db.redis.sadd(key, ...allIDs); | ||
for (const requestId2 of diff) { | ||
await db.redis.hset(key, { [requestId2]: incrementBy }); | ||
} | ||
} | ||
@@ -419,32 +508,4 @@ } | ||
const windowSize = ms(window); | ||
const script = ` | ||
local currentKey = KEYS[1] -- identifier including prefixes | ||
local previousKey = KEYS[2] -- key of the previous bucket | ||
local tokens = tonumber(ARGV[1]) -- tokens per window | ||
local now = ARGV[2] -- current timestamp in milliseconds | ||
local window = ARGV[3] -- interval in milliseconds | ||
local requestId = ARGV[4] -- uuid for this request | ||
local currentMembers = redis.call("SMEMBERS", currentKey) | ||
local requestsInCurrentWindow = #currentMembers | ||
local previousMembers = redis.call("SMEMBERS", previousKey) | ||
local requestsInPreviousWindow = #previousMembers | ||
local percentageInCurrent = ( now % window) / window | ||
if requestsInPreviousWindow * ( 1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then | ||
return {currentMembers, previousMembers, false} | ||
end | ||
redis.call("SADD", currentKey, requestId) | ||
table.insert(currentMembers, requestId) | ||
if requestsInCurrentWindow == 0 then | ||
-- The first time this key is set, the value will be 1. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second | ||
end | ||
return {currentMembers, previousMembers, true} | ||
`; | ||
const windowDuration = ms(window); | ||
return async function(ctx, identifier) { | ||
return async (ctx, identifier, rate) => { | ||
const requestId = randomId(); | ||
@@ -456,8 +517,9 @@ const now = Date.now(); | ||
const previousKey = [identifier, previousWindow].join(":"); | ||
const incrementBy = rate ? Math.max(1, rate) : 1; | ||
const dbs = ctx.redis.map((redis) => ({ | ||
redis, | ||
request: redis.eval( | ||
script, | ||
slidingWindowScript, | ||
[currentKey, previousKey], | ||
[tokens, now, windowDuration, requestId] | ||
[tokens, now, windowDuration, requestId, incrementBy] | ||
// lua seems to return `1` for true and `null` for false | ||
@@ -468,18 +530,52 @@ ) | ||
const [current, previous, success] = await Promise.any(dbs.map((s) => s.request)); | ||
const previousPartialUsed = previous.length * (1 - percentageInCurrent); | ||
const usedTokens = previousPartialUsed + current.length; | ||
const previousUsedTokens = previous.reduce((accTokens, usedToken, index) => { | ||
let parsedToken = 0; | ||
if (index % 2) { | ||
parsedToken = Number.parseInt(usedToken); | ||
} | ||
return accTokens + parsedToken; | ||
}, 0); | ||
const currentUsedTokens = current.reduce((accTokens, usedToken, index) => { | ||
let parsedToken = 0; | ||
if (index % 2) { | ||
parsedToken = Number.parseInt(usedToken); | ||
} | ||
return accTokens + parsedToken; | ||
}, 0); | ||
const previousPartialUsed = previousUsedTokens * (1 - percentageInCurrent); | ||
const usedTokens = previousPartialUsed + currentUsedTokens; | ||
const remaining = tokens - usedTokens; | ||
async function sync() { | ||
const res = await Promise.all(dbs.map((s) => s.request)); | ||
const allCurrentIds = res.flatMap(([current2]) => current2); | ||
const allCurrentIds = res.flatMap(([current2]) => current2).reduce((accCurrentIds, curr, index) => { | ||
if (index % 2 === 0) { | ||
accCurrentIds.push(curr); | ||
} | ||
return accCurrentIds; | ||
}, []); | ||
for (const db of dbs) { | ||
const [ids] = await db.request; | ||
if (ids.length >= tokens) { | ||
const [_current, previous2, _success] = await db.request; | ||
const dbIds = previous2.reduce((ids, currentId, index) => { | ||
if (index % 2 === 0) { | ||
ids.push(currentId); | ||
} | ||
return ids; | ||
}, []); | ||
const usedDbTokens = previous2.reduce((accTokens, usedToken, index) => { | ||
let parsedToken = 0; | ||
if (index % 2) { | ||
parsedToken = Number.parseInt(usedToken); | ||
} | ||
return accTokens + parsedToken; | ||
}, 0); | ||
if (usedDbTokens >= tokens) { | ||
continue; | ||
} | ||
const diff = allCurrentIds.filter((id) => !ids.includes(id)); | ||
const diff = allCurrentIds.filter((id) => !dbIds.includes(id)); | ||
if (diff.length === 0) { | ||
continue; | ||
} | ||
await db.redis.sadd(currentKey, ...diff); | ||
for (const requestId2 of diff) { | ||
await db.redis.hset(currentKey, { [requestId2]: incrementBy }); | ||
} | ||
} | ||
@@ -494,3 +590,3 @@ } | ||
limit: tokens, | ||
remaining, | ||
remaining: Math.max(0, remaining), | ||
reset, | ||
@@ -503,2 +599,103 @@ pending: sync() | ||
// src/lua-scripts/single.ts | ||
var fixedWindowScript2 = ` | ||
local key = KEYS[1] | ||
local window = ARGV[1] | ||
local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1 | ||
local r = redis.call("INCRBY", key, incrementBy) | ||
if r == incrementBy then | ||
-- The first time this key is set, the value will be equal to incrementBy. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", key, window) | ||
end | ||
return r | ||
`; | ||
var slidingWindowScript2 = ` | ||
local currentKey = KEYS[1] -- identifier including prefixes | ||
local previousKey = KEYS[2] -- key of the previous bucket | ||
local tokens = tonumber(ARGV[1]) -- tokens per window | ||
local now = ARGV[2] -- current timestamp in milliseconds | ||
local window = ARGV[3] -- interval in milliseconds | ||
local incrementBy = ARGV[4] -- increment rate per request at a given value, default is 1 | ||
local requestsInCurrentWindow = redis.call("GET", currentKey) | ||
if requestsInCurrentWindow == false then | ||
requestsInCurrentWindow = 0 | ||
end | ||
local requestsInPreviousWindow = redis.call("GET", previousKey) | ||
if requestsInPreviousWindow == false then | ||
requestsInPreviousWindow = 0 | ||
end | ||
local percentageInCurrent = ( now % window ) / window | ||
-- weighted requests to consider from the previous window | ||
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow) | ||
if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then | ||
return -1 | ||
end | ||
local newValue = redis.call("INCRBY", currentKey, incrementBy) | ||
if newValue == incrementBy then | ||
-- The first time this key is set, the value will be equal to incrementBy. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second | ||
end | ||
return tokens - ( newValue + requestsInPreviousWindow ) | ||
`; | ||
var tokenBucketScript = ` | ||
local key = KEYS[1] -- identifier including prefixes | ||
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens | ||
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds | ||
local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval | ||
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds | ||
local incrementBy = tonumber(ARGV[5]) -- how many tokens to consume, default is 1 | ||
local bucket = redis.call("HMGET", key, "refilledAt", "tokens") | ||
local refilledAt | ||
local tokens | ||
if bucket[1] == false then | ||
refilledAt = now | ||
tokens = maxTokens | ||
else | ||
refilledAt = tonumber(bucket[1]) | ||
tokens = tonumber(bucket[2]) | ||
end | ||
if now >= refilledAt + interval then | ||
local numRefills = math.floor((now - refilledAt) / interval) | ||
tokens = math.min(maxTokens, tokens + numRefills * refillRate) | ||
refilledAt = refilledAt + numRefills * interval | ||
end | ||
if tokens == 0 then | ||
return {-1, refilledAt + interval} | ||
end | ||
local remaining = tokens - incrementBy | ||
local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval | ||
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining) | ||
redis.call("PEXPIRE", key, expireAt) | ||
return {remaining, refilledAt + interval} | ||
`; | ||
var cachedFixedWindowScript = ` | ||
local key = KEYS[1] | ||
local window = ARGV[1] | ||
local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1 | ||
local r = redis.call("INCRBY", key, incrementBy) | ||
if r == incrementBy then | ||
-- The first time this key is set, the value will be equal to incrementBy. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", key, window) | ||
end | ||
return r | ||
`; | ||
// src/single.ts | ||
@@ -541,16 +738,3 @@ var RegionRatelimit = class extends Ratelimit { | ||
const windowDuration = ms(window); | ||
const script = ` | ||
local key = KEYS[1] | ||
local window = ARGV[1] | ||
local r = redis.call("INCR", key) | ||
if r == 1 then | ||
-- The first time this key is set, the value will be 1. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", key, window) | ||
end | ||
return r | ||
`; | ||
return async function(ctx, identifier) { | ||
return async (ctx, identifier, rate) => { | ||
const bucket = Math.floor(Date.now() / windowDuration); | ||
@@ -570,8 +754,10 @@ const key = [identifier, bucket].join(":"); | ||
} | ||
const incrementBy = rate ? Math.max(1, rate) : 1; | ||
const usedTokensAfterUpdate = await ctx.redis.eval( | ||
script, | ||
fixedWindowScript2, | ||
[key], | ||
[windowDuration] | ||
[windowDuration, incrementBy] | ||
); | ||
const success = usedTokensAfterUpdate <= tokens; | ||
const remainingTokens = Math.max(0, tokens - usedTokensAfterUpdate); | ||
const reset = (bucket + 1) * windowDuration; | ||
@@ -584,3 +770,3 @@ if (ctx.cache && !success) { | ||
limit: tokens, | ||
remaining: Math.max(0, tokens - usedTokensAfterUpdate), | ||
remaining: remainingTokens, | ||
reset, | ||
@@ -608,35 +794,4 @@ pending: Promise.resolve() | ||
static slidingWindow(tokens, window) { | ||
const script = ` | ||
local currentKey = KEYS[1] -- identifier including prefixes | ||
local previousKey = KEYS[2] -- key of the previous bucket | ||
local tokens = tonumber(ARGV[1]) -- tokens per window | ||
local now = ARGV[2] -- current timestamp in milliseconds | ||
local window = ARGV[3] -- interval in milliseconds | ||
local requestsInCurrentWindow = redis.call("GET", currentKey) | ||
if requestsInCurrentWindow == false then | ||
requestsInCurrentWindow = 0 | ||
end | ||
local requestsInPreviousWindow = redis.call("GET", previousKey) | ||
if requestsInPreviousWindow == false then | ||
requestsInPreviousWindow = 0 | ||
end | ||
local percentageInCurrent = ( now % window ) / window | ||
-- weighted requests to consider from the previous window | ||
requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow) | ||
if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then | ||
return -1 | ||
end | ||
local newValue = redis.call("INCR", currentKey) | ||
if newValue == 1 then | ||
-- The first time this key is set, the value will be 1. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second | ||
end | ||
return tokens - ( newValue + requestsInPreviousWindow ) | ||
`; | ||
const windowSize = ms(window); | ||
return async function(ctx, identifier) { | ||
return async (ctx, identifier, rate) => { | ||
const now = Date.now(); | ||
@@ -659,8 +814,9 @@ const currentWindow = Math.floor(now / windowSize); | ||
} | ||
const remaining = await ctx.redis.eval( | ||
script, | ||
const incrementBy = rate ? Math.max(1, rate) : 1; | ||
const remainingTokens = await ctx.redis.eval( | ||
slidingWindowScript2, | ||
[currentKey, previousKey], | ||
[tokens, now, windowSize] | ||
[tokens, now, windowSize, incrementBy] | ||
); | ||
const success = remaining >= 0; | ||
const success = remainingTokens >= 0; | ||
const reset = (currentWindow + 1) * windowSize; | ||
@@ -673,3 +829,3 @@ if (ctx.cache && !success) { | ||
limit: tokens, | ||
remaining: Math.max(0, remaining), | ||
remaining: Math.max(0, remainingTokens), | ||
reset, | ||
@@ -694,42 +850,4 @@ pending: Promise.resolve() | ||
static tokenBucket(refillRate, interval, maxTokens) { | ||
const script = ` | ||
local key = KEYS[1] -- identifier including prefixes | ||
local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens | ||
local interval = tonumber(ARGV[2]) -- size of the window in milliseconds | ||
local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval | ||
local now = tonumber(ARGV[4]) -- current timestamp in milliseconds | ||
local bucket = redis.call("HMGET", key, "refilledAt", "tokens") | ||
local refilledAt | ||
local tokens | ||
if bucket[1] == false then | ||
refilledAt = now | ||
tokens = maxTokens | ||
else | ||
refilledAt = tonumber(bucket[1]) | ||
tokens = tonumber(bucket[2]) | ||
end | ||
if now >= refilledAt + interval then | ||
local numRefills = math.floor((now - refilledAt) / interval) | ||
tokens = math.min(maxTokens, tokens + numRefills * refillRate) | ||
refilledAt = refilledAt + numRefills * interval | ||
end | ||
if tokens == 0 then | ||
return {-1, refilledAt + interval} | ||
end | ||
local remaining = tokens - 1 | ||
local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval | ||
redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining) | ||
redis.call("PEXPIRE", key, expireAt) | ||
return {remaining, refilledAt + interval} | ||
`; | ||
const intervalDuration = ms(interval); | ||
return async function(ctx, identifier) { | ||
return async (ctx, identifier, rate) => { | ||
if (ctx.cache) { | ||
@@ -748,6 +866,7 @@ const { blocked, reset: reset2 } = ctx.cache.isBlocked(identifier); | ||
const now = Date.now(); | ||
const incrementBy = rate ? Math.max(1, rate) : 1; | ||
const [remaining, reset] = await ctx.redis.eval( | ||
script, | ||
tokenBucketScript, | ||
[identifier], | ||
[maxTokens, intervalDuration, refillRate, now] | ||
[maxTokens, intervalDuration, refillRate, now, incrementBy] | ||
); | ||
@@ -793,16 +912,3 @@ const success = remaining >= 0; | ||
const windowDuration = ms(window); | ||
const script = ` | ||
local key = KEYS[1] | ||
local window = ARGV[1] | ||
local r = redis.call("INCR", key) | ||
if r == 1 then | ||
-- The first time this key is set, the value will be 1. | ||
-- So we only need the expire command once | ||
redis.call("PEXPIRE", key, window) | ||
end | ||
return r | ||
`; | ||
return async function(ctx, identifier) { | ||
return async (ctx, identifier, rate) => { | ||
if (!ctx.cache) { | ||
@@ -814,2 +920,3 @@ throw new Error("This algorithm requires a cache"); | ||
const reset = (bucket + 1) * windowDuration; | ||
const incrementBy = rate ? Math.max(1, rate) : 1; | ||
const hit = typeof ctx.cache.get(key) === "number"; | ||
@@ -819,3 +926,3 @@ if (hit) { | ||
const success = cachedTokensAfterUpdate < tokens; | ||
const pending = success ? ctx.redis.eval(script, [key], [windowDuration]).then((t) => { | ||
const pending = success ? ctx.redis.eval(cachedFixedWindowScript, [key], [windowDuration, incrementBy]).then((t) => { | ||
ctx.cache.set(key, t); | ||
@@ -832,5 +939,5 @@ }) : Promise.resolve(); | ||
const usedTokensAfterUpdate = await ctx.redis.eval( | ||
script, | ||
cachedFixedWindowScript, | ||
[key], | ||
[windowDuration] | ||
[windowDuration, incrementBy] | ||
); | ||
@@ -837,0 +944,0 @@ ctx.cache.set(key, usedTokensAfterUpdate); |
@@ -1,1 +0,1 @@ | ||
{ "name": "@upstash/ratelimit", "version": "v1.0.1", "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "tsup", "test": "bun test src --coverage", "fmt": "bunx @biomejs/biome check --apply ./src" }, "devDependencies": { "@upstash/redis": "^1.28.3", "bun-types": "latest", "rome": "^11.0.0", "turbo": "^1.10.15", "tsup": "^7.2.0", "typescript": "^5.0.0" }, "dependencies": { "@upstash/core-analytics": "^0.0.7" } } | ||
{ "name": "@upstash/ratelimit", "version": "v1.0.3", "main": "./dist/index.js", "types": "./dist/index.d.ts", "files": [ "dist" ], "scripts": { "build": "tsup", "test": "bun test src --coverage", "fmt": "bunx @biomejs/biome check --apply ./src" }, "devDependencies": { "@upstash/redis": "^1.28.3", "bun-types": "latest", "rome": "^11.0.0", "turbo": "^1.10.15", "tsup": "^7.2.0", "typescript": "^5.0.0" }, "dependencies": { "@upstash/core-analytics": "^0.0.7" }, "license": "MIT" } |
359
README.md
@@ -6,2 +6,6 @@ # Upstash Rate Limit | ||
> [!NOTE] | ||
> **This project is in GA Stage.** | ||
> The Upstash Professional Support fully covers this project. It receives regular updates, and bug fixes. The Upstash team is committed to maintaining and improving its functionality. | ||
It is the only connectionless (HTTP based) rate limiting library and designed | ||
@@ -19,40 +23,2 @@ for: | ||
<!-- toc --> | ||
- [Docs](#docs) | ||
- [Quick Start](#quick-start) | ||
- [Install](#install) | ||
- [npm](#npm) | ||
- [Deno](#deno) | ||
- [Create database](#create-database) | ||
- [Use it](#use-it) | ||
- [Block until ready](#block-until-ready) | ||
- [Ephemeral Cache](#ephemeral-cache) | ||
- [MultiRegion replicated ratelimiting](#multiregion-replicated-ratelimiting) | ||
- [Usage](#usage) | ||
- [Asynchronous synchronization between databases](#asynchronous-synchronization-between-databases) | ||
- [Example](#example) | ||
- [Ratelimiting algorithms](#ratelimiting-algorithms) | ||
- [Fixed Window](#fixed-window) | ||
- [Pros:](#pros) | ||
- [Cons:](#cons) | ||
- [Usage:](#usage) | ||
- [Sliding Window](#sliding-window) | ||
- [Pros:](#pros-1) | ||
- [Cons:](#cons-1) | ||
- [Usage:](#usage-1) | ||
- [Token Bucket](#token-bucket) | ||
- [Pros:](#pros-2) | ||
- [Cons:](#cons-2) | ||
- [Usage:](#usage-2) | ||
- [Contributing](#contributing) | ||
- [Database](#database) | ||
- [Running tests](#running-tests) | ||
<!-- tocstop --> | ||
## Docs | ||
[doc.deno.land](https://deno.land/x/upstash_ratelimit/packages/sdk/src/index.ts) | ||
## Quick Start | ||
@@ -78,3 +44,3 @@ | ||
### Use it | ||
### Basic Usage | ||
@@ -169,318 +135,7 @@ See [here](https://github.com/upstash/upstash-redis#quick-start) for | ||
### Timeout | ||
### Docs | ||
See [the documentation](https://upstash.com/docs/oss/sdks/ts/ratelimit/overview) for details. | ||
You can define an optional timeout in milliseconds, after which the request will | ||
be allowed to pass regardless of what the current limit is. This can be useful | ||
if you don't want network issues to cause your application to reject requests. | ||
```ts | ||
const ratelimit = new Ratelimit({ | ||
redis: Redis.fromEnv(), | ||
limiter: Ratelimit.slidingWindow(10, "10 s"), | ||
timeout: 1000, // 1 second | ||
analytics: true | ||
}); | ||
``` | ||
### Block until ready | ||
In case you don't want to reject a request immediately but wait until it can be | ||
processed, we also provide | ||
```ts | ||
ratelimit.blockUntilReady(identifier: string, timeout: number): Promise<RatelimitResponse> | ||
``` | ||
It is very similar to the `limit` method and takes an identifier and returns the | ||
same response. However if the current limit has already been exceeded, it will | ||
automatically wait until the next window starts and will try again. Setting the | ||
timeout parameter (in milliseconds) will cause the returned Promise to resolve | ||
in a finite amount of time. | ||
```ts | ||
// Create a new ratelimiter, that allows 10 requests per 10 seconds | ||
const ratelimit = new Ratelimit({ | ||
redis: Redis.fromEnv(), | ||
limiter: Ratelimit.slidingWindow(10, "10 s"), | ||
analytics: true | ||
}); | ||
// `blockUntilReady` returns a promise that resolves as soon as the request is allowed to be processed, or after 30 seconds | ||
const { success } = await ratelimit.blockUntilReady("id", 30_000); | ||
if (!success) { | ||
return "Unable to process, even after 30 seconds"; | ||
} | ||
doExpensiveCalculation(); | ||
return "Here you go!"; | ||
``` | ||
### Ephemeral Cache | ||
For extreme load or denial of service attacks, it might be too expensive to call | ||
redis for every incoming request, just to find out it should be blocked because | ||
they have exceeded the limit. | ||
You can use an ephemeral in memory cache by passing the `ephemeralCache` option: | ||
```ts | ||
const cache = new Map(); // must be outside of your serverless function handler | ||
// ... | ||
const ratelimit = new Ratelimit({ | ||
// ... | ||
ephemeralCache: cache, | ||
}); | ||
``` | ||
If enabled, the ratelimiter will keep a global cache of identifiers and their | ||
reset timestamps, that have exhausted their ratelimit. In serverless | ||
environments this is only possible if you create the cache or ratelimiter | ||
instance outside of your handler function. While the function is still hot, the | ||
ratelimiter can block requests without having to request data from redis, thus | ||
saving time and money. | ||
## Using multiple limits | ||
Sometimes you might want to apply different limits to different users. For example you might want to allow 10 requests per 10 seconds for free users, but 60 requests per 10 seconds for paid users. | ||
Here's how you could do that: | ||
```ts | ||
import { Redis } from "@upstash/redis" | ||
import { Ratelimit } from "@upstash/ratelimit" | ||
const redis = Redis.fromEnv() | ||
const ratelimit = { | ||
free: new Ratelimit({ | ||
redis, | ||
analytics: true, | ||
prefix: "ratelimit:free", | ||
limiter: Ratelimit.slidingWindow(10, "10s"), | ||
}), | ||
paid: new Ratelimit({ | ||
redis, | ||
analytics: true, | ||
prefix: "ratelimit:paid", | ||
limiter: Ratelimit.slidingWindow(60, "10s"), | ||
}) | ||
} | ||
await ratelimit.free.limit(ip) | ||
// or for a paid user you might have an email or userId available: | ||
await ratelimit.paid.limit(userId) | ||
``` | ||
## MultiRegion replicated ratelimiting | ||
Using a single redis instance has the downside of providing low latencies only | ||
to the part of your userbase closest to the deployed db. That's why we also | ||
built `MultiRegionRatelimit` which replicates the state across multiple redis | ||
databases as well as offering lower latencies to more of your users. | ||
`MultiRegionRatelimit` does this by checking the current limit in the closest db | ||
and returning immediately. Only afterwards will the state be asynchronously | ||
replicated to the other datbases leveraging | ||
[CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type). Due | ||
to the nature of distributed systems, there is no way to guarantee the set | ||
ratelimit is not exceeded by a small margin. This is the tradeoff for reduced | ||
global latency. | ||
### Usage | ||
The api is the same, except for asking for multiple redis instances: | ||
```ts | ||
import { MultiRegionRatelimit } from "@upstash/ratelimit"; // for deno: see above | ||
import { Redis } from "@upstash/redis"; | ||
// Create a new ratelimiter, that allows 10 requests per 10 seconds | ||
const ratelimit = new MultiRegionRatelimit({ | ||
redis: [ | ||
new Redis({ | ||
/* auth */ | ||
}), | ||
new Redis({ | ||
/* auth */ | ||
}), | ||
new Redis({ | ||
/* auth */ | ||
}), | ||
], | ||
limiter: MultiRegionRatelimit.slidingWindow(10, "10 s"), | ||
analytics: true | ||
}); | ||
// Use a constant string to limit all requests with a single ratelimit | ||
// Or use a userID, apiKey or ip address for individual limits. | ||
const identifier = "api"; | ||
const { success } = await ratelimit.limit(identifier); | ||
``` | ||
### Asynchronous synchronization between databases | ||
The MultiRegion setup will do some synchronization between databases after | ||
returning the current limit. This can lead to problems on Cloudflare Workers and | ||
therefore Vercel Edge functions, because dangling promises must be taken care | ||
of: | ||
**Vercel Edge:** | ||
[docs](https://nextjs.org/docs/api-reference/next/server#nextfetchevent) | ||
```ts | ||
const { pending } = await ratelimit.limit("id"); | ||
event.waitUntil(pending); | ||
``` | ||
**Cloudflare Worker:** | ||
[docs](https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#syntax-module-worker) | ||
```ts | ||
const { pending } = await ratelimit.limit("id"); | ||
context.waitUntil(pending); | ||
``` | ||
### Example | ||
Let's assume you have customers in the US and Europe. In this case you can | ||
create 2 regional redis databases on [Upstash](https://console.upstash.com) and | ||
your users will enjoy the latency of whichever db is closest to them. | ||
## Ratelimiting algorithms | ||
We provide different algorithms to use out of the box. Each has pros and cons. | ||
### Fixed Window | ||
This algorithm divides time into fixed durations/windows. For example each | ||
window is 10 seconds long. When a new request comes in, the current time is used | ||
to determine the window and a counter is increased. If the counter is larger | ||
than the set limit, the request is rejected. | ||
#### Pros: | ||
- Very cheap in terms of data size and computation | ||
- Newer requests are not starved due to a high burst in the past | ||
#### Cons: | ||
- Can cause high bursts at the window boundaries to leak through | ||
- Causes request stampedes if many users are trying to access your server, | ||
whenever a new window begins | ||
#### Usage: | ||
Create a new ratelimiter, that allows 10 requests per 10 seconds. | ||
```ts | ||
const ratelimit = new Ratelimit({ | ||
redis: Redis.fromEnv(), | ||
limiter: Ratelimit.fixedWindow(10, "10 s"), | ||
analytics: true | ||
}); | ||
``` | ||
### Sliding Window | ||
Builds on top of fixed window but instead of a fixed window, we use a rolling | ||
window. Take this example: We have a rate limit of 10 requests per 1 minute. We | ||
divide time into 1 minute slices, just like in the fixed window algorithm. | ||
Window 1 will be from 00:00:00 to 00:01:00 (HH:MM:SS). Let's assume it is | ||
currently 00:01:15 and we have received 4 requests in the first window and 5 | ||
requests so far in the current window. The approximation to determine if the | ||
request should pass works like this: | ||
```ts | ||
limit = 10 | ||
// 4 request from the old window, weighted + requests in current window | ||
rate = 4 * ((60 - 15) / 60) + 5 = 8 | ||
return rate < limit // True means we should allow the request | ||
``` | ||
#### Pros: | ||
- Solves the issue near boundary from fixed window. | ||
#### Cons: | ||
- More expensive in terms of storage and computation | ||
- Is only an approximation, because it assumes a uniform request flow in the | ||
previous window, but this is fine in most cases | ||
#### Usage: | ||
Create a new ratelimiter, that allows 10 requests per 10 seconds. | ||
```ts | ||
const ratelimit = new Ratelimit({ | ||
redis: Redis.fromEnv(), | ||
limiter: Ratelimit.slidingWindow(10, "10 s"), | ||
analytics: true | ||
}); | ||
``` | ||
### Token Bucket | ||
_Not yet supported for `MultiRegionRatelimit`_ | ||
Consider a bucket filled with `{maxTokens}` tokens that refills constantly at | ||
`{refillRate}` per `{interval}`. Every request will remove one token from the | ||
bucket and if there is no token to take, the request is rejected. | ||
#### Pros: | ||
- Bursts of requests are smoothed out and you can process them at a constant | ||
rate. | ||
- Allows to set a higher initial burst limit by setting `maxTokens` higher than | ||
`refillRate` | ||
#### Cons: | ||
- Expensive in terms of computation | ||
#### Usage: | ||
Create a new bucket, that refills 5 tokens every 10 seconds and has a maximum | ||
size of 10. | ||
```ts | ||
const ratelimit = new Ratelimit({ | ||
redis: Redis.fromEnv(), | ||
limiter: Ratelimit.tokenBucket(5, "10 s", 10), | ||
analytics: true | ||
}); | ||
``` | ||
## Analytics | ||
You can enable analytics to get a better understanding of how your ratelimiting | ||
is performing. This is done by setting `analytics: true` in the options. | ||
All data is stored in the same Redis database and writing analytics uses 1 command per `.limit` invocation. | ||
```ts | ||
const ratelimit = new Ratelimit({ | ||
redis: Redis.fromEnv(), | ||
limiter: Ratelimit.tokenBucket(5, "10 s", 10), | ||
analytics: true // <- Enable analytics | ||
}); | ||
``` | ||
Go to the [Ratelimit Dashboard](https://console.upstash.com/ratelimit) and select the database you are using. | ||
If you are using a custom prefix, you need to use the same in the dashboard's top right corner. | ||
 | ||
## Contributing | ||
### Database | ||
@@ -487,0 +142,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Manifest confusion
Supply chain riskThis package has inconsistent metadata. This could be malicious or caused by an error when publishing the package.
Found 1 instance in 1 package
Manifest confusion
Supply chain riskThis package has inconsistent metadata. This could be malicious or caused by an error when publishing the package.
Found 1 instance in 1 package
218825
6.42%2358
10.76%147
-70.12%