@upstash/ratelimit 0.1.2 → 0.1.4-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.releaserc ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "branches": [
3
+ {
4
+ "name": "release"
5
+ },
6
+ {
7
+ "name": "main",
8
+ "channel": "next",
9
+ "prerelease": "next"
10
+ }
11
+ ],
12
+ "dryRun": false,
13
+ "ci": true
14
+ }
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Upstash Redis
1
+ # Upstash Ratelimit
2
2
 
3
3
  An HTTP/REST based Redis client built on top of Upstash REST API.
4
4
  [Upstash REST API](https://docs.upstash.com/features/restapi).
@@ -25,8 +25,9 @@ It is the only connectionless (HTTP based) ratelimiter and designed for:
25
25
  - [Create database](#create-database)
26
26
  - [Use it](#use-it)
27
27
  - [Block until ready](#block-until-ready)
28
- - [Globally replicated ratelimiting](#globally-replicated-ratelimiting)
28
+ - [MultiRegionly replicated ratelimiting](#multiregionly-replicated-ratelimiting)
29
29
  - [Usage](#usage)
30
+ - [Asynchronous synchronization between databases](#asynchronous-synchronization-between-databases)
30
31
  - [Example](#example)
31
32
  - [Ratelimiting algorithms](#ratelimiting-algorithms)
32
33
  - [Fixed Window](#fixed-window)
@@ -99,29 +100,50 @@ return "Here you go!";
99
100
 
100
101
  The `limit` method returns some more metadata that might be useful to you:
101
102
 
102
- ```ts
103
+ ````ts
103
104
  export type RatelimitResponse = {
104
105
  /**
105
106
  * Whether the request may pass(true) or exceeded the limit(false)
106
107
  */
107
108
  success: boolean;
108
-
109
109
  /**
110
110
  * Maximum number of requests allowed within a window.
111
111
  */
112
112
  limit: number;
113
-
114
113
  /**
115
114
  * How many requests the user has left within the current window.
116
115
  */
117
116
  remaining: number;
118
-
119
117
  /**
120
118
  * Unix timestamp in milliseconds when the limits are reset.
121
119
  */
122
120
  reset: number;
121
+
122
+ /**
123
+ * For the MultiRegion setup we do some synchronizing in the background, after returning the current limit.
124
+ * In most case you can simply ignore this.
125
+ *
126
+ * On Vercel Edge or Cloudflare workers, you need to explicitely handle the pending Promise like this:
127
+ *
128
+ * **Vercel Edge:**
129
+ * https://nextjs.org/docs/api-reference/next/server#nextfetchevent
130
+ *
131
+ * ```ts
132
+ * const { pending } = await ratelimit.limit("id")
133
+ * event.waitUntil(pending)
134
+ * ```
135
+ *
136
+ * **Cloudflare Worker:**
137
+ * https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#syntax-module-worker
138
+ *
139
+ * ```ts
140
+ * const { pending } = await ratelimit.limit("id")
141
+ * context.waitUntil(pending)
142
+ * ```
143
+ */
144
+ pending: Promise<unknown>;
123
145
  };
124
- ```
146
+ ````
125
147
 
126
148
  ### Block until ready
127
149
 
@@ -155,15 +177,45 @@ doExpensiveCalculation();
155
177
  return "Here you go!";
156
178
  ```
157
179
 
158
- ## Globally replicated ratelimiting
180
+ ### Ephemeral Cache
181
+
182
+ For extreme load or denial of service attacks, it might be too expensive to call
183
+ redis for every incoming request, just to find out the request should be blocked
184
+ because they have exceeded the limit.
185
+
186
+ You can use an ephemeral in memory cache by passing the `ephemeralCache`
187
+ options:
188
+
189
+ ```ts
190
+ const cache = new Map(); // must be outside of your serverless function handler
191
+
192
+ // ...
193
+
194
+ const ratelimit = new Ratelimit({
195
+ // ...
196
+ ephemeralCache: cache,
197
+ });
198
+ ```
199
+
200
+ If enabled, the ratelimiter will keep a global cache of identifiers and a reset
201
+ timestamp, that have exhausted their ratelimit. In serverless environments this
202
+ is only possible if you create the ratelimiter instance outside of your handler
203
+ function. While the function is still hot, the ratelimiter can block requests
204
+ without having to request data from redis, thus saving time and money.
205
+
206
+ Whenever an identifier has exceeded its limit, the ratelimiter will add it to an
207
+ internal list together with its reset timestamp. If the same identifier makes a
208
+ new request before it is reset, we can immediately reject it.
209
+
210
+ ## MultiRegionly replicated ratelimiting
159
211
 
160
212
  Using a single redis instance has the downside of providing low latencies to the
161
213
  part of your userbase closest to the deployed db. That's why we also built
162
- `GlobalRatelimit` which replicates the state across multiple redis databases as
163
- well as offering lower latencies to more of your users.
214
+ `MultiRegionRatelimit` which replicates the state across multiple redis
215
+ databases as well as offering lower latencies to more of your users.
164
216
 
165
- `GlobalRatelimit` does this by checking the current limit in the closest db and
166
- returning immediately. Only afterwards will the state be asynchronously
217
+ `MultiRegionRatelimit` does this by checking the current limit in the closest db
218
+ and returning immediately. Only afterwards will the state be asynchronously
167
219
  replicated to the other datbases leveraging
168
220
  [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type). Due
169
221
  to the nature of distributed systems, there is no way to guarantee the set
@@ -175,15 +227,21 @@ global latency.
175
227
  The api is the same, except for asking for multiple redis instances:
176
228
 
177
229
  ```ts
178
- import { GlobalRatelimit } from "@upstash/ratelimit"; // for deno: see above
230
+ import { MultiRegionRatelimit } from "@upstash/ratelimit"; // for deno: see above
179
231
  import { Redis } from "@upstash/redis";
180
232
 
181
233
  // Create a new ratelimiter, that allows 10 requests per 10 seconds
182
- const ratelimit = new GlobalRatelimit({
234
+ const ratelimit = new MultiRegionRatelimit({
183
235
  redis: [
184
- new Redis({/* auth */}),
185
- new Redis({/* auth */}),
186
- new Redis({/* auth */}),
236
+ new Redis({
237
+ /* auth */
238
+ }),
239
+ new Redis({
240
+ /* auth */
241
+ }),
242
+ new Redis({
243
+ /* auth */
244
+ }),
187
245
  ],
188
246
  limiter: Ratelimit.slidingWindow(10, "10 s"),
189
247
  });
@@ -194,6 +252,29 @@ const identifier = "api";
194
252
  const { success } = await ratelimit.limit(identifier);
195
253
  ```
196
254
 
255
+ ### Asynchronous synchronization between databases
256
+
257
+ The MultiRegion setup will do some synchronization between databases after
258
+ returning the current limit. This can lead to problems on Cloudflare Workers and
259
+ therefore Vercel Edge functions, because dangling promises must be taken care
260
+ of:
261
+
262
+ **Vercel Edge:**
263
+ [docs](https://nextjs.org/docs/api-reference/next/server#nextfetchevent)
264
+
265
+ ```ts
266
+ const { pending } = await ratelimit.limit("id");
267
+ event.waitUntil(pending);
268
+ ```
269
+
270
+ **Cloudflare Worker:**
271
+ [docs](https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#syntax-module-worker)
272
+
273
+ ```ts
274
+ const { pending } = await ratelimit.limit("id");
275
+ context.waitUntil(pending);
276
+ ```
277
+
197
278
  ### Example
198
279
 
199
280
  Let's assume you have customers in the US and Europe. In this case you can
@@ -275,7 +356,7 @@ const ratelimit = new Ratelimit({
275
356
 
276
357
  ### Token Bucket
277
358
 
278
- _Not yet supported for `GlobalRatelimit`_
359
+ _Not yet supported for `MultiRegionRatelimit`_
279
360
 
280
361
  Consider a bucket filled with `{maxTokens}` tokens that refills constantly at
281
362
  `{refillRate}` per `{interval}`. Every request will remove one token from the
package/esm/cache.js ADDED
@@ -0,0 +1,29 @@
1
+ export class Cache {
2
+ constructor(cache) {
3
+ /**
4
+ * Stores identifier -> reset (in milliseconds)
5
+ */
6
+ Object.defineProperty(this, "cache", {
7
+ enumerable: true,
8
+ configurable: true,
9
+ writable: true,
10
+ value: void 0
11
+ });
12
+ this.cache = cache;
13
+ }
14
+ isBlocked(identifier) {
15
+ if (!this.cache.has(identifier)) {
16
+ return { blocked: false, reset: 0 };
17
+ }
18
+ const reset = this.cache.get(identifier);
19
+ if (reset < Date.now()) {
20
+ this.cache.delete(identifier);
21
+ return { blocked: false, reset: 0 };
22
+ }
23
+ console.log(`[CACHE] isBlocked(${identifier}) -> true`);
24
+ return { blocked: true, reset: reset };
25
+ }
26
+ blockUntil(identifier, reset) {
27
+ this.cache.set(identifier, reset);
28
+ }
29
+ }
package/esm/mod.js CHANGED
@@ -1,2 +1,2 @@
1
- export { RegionRatelimit as Ratelimit } from "./region.js";
2
- export { GlobalRatelimit } from "./global.js";
1
+ export { RegionRatelimit as Ratelimit } from "./single.js";
2
+ export { MultiRegionRatelimit } from "./multi.js";
@@ -1,13 +1,14 @@
1
1
  import { ms } from "./duration.js";
2
2
  import { Ratelimit } from "./ratelimit.js";
3
+ import { Cache } from "./cache.js";
3
4
  /**
4
5
  * Ratelimiter using serverless redis from https://upstash.com/
5
6
  *
6
7
  * @example
7
8
  * ```ts
8
- * const { limit } = new GlobalRatelimit({
9
+ * const { limit } = new MultiRegionRatelimit({
9
10
  * redis: Redis.fromEnv(),
10
- * limiter: GlobalRatelimit.fixedWindow(
11
+ * limiter: MultiRegionRatelimit.fixedWindow(
11
12
  * 10, // Allow 10 requests per window of 30 minutes
12
13
  * "30 m", // interval of 30 minutes
13
14
  * )
@@ -15,7 +16,7 @@ import { Ratelimit } from "./ratelimit.js";
15
16
  *
16
17
  * ```
17
18
  */
18
- export class GlobalRatelimit extends Ratelimit {
19
+ export class MultiRegionRatelimit extends Ratelimit {
19
20
  /**
20
21
  * Create a new Ratelimit instance by providing a `@upstash/redis` instance and the algorithn of your choice.
21
22
  */
@@ -23,7 +24,10 @@ export class GlobalRatelimit extends Ratelimit {
23
24
  super({
24
25
  prefix: config.prefix,
25
26
  limiter: config.limiter,
26
- ctx: { redis: config.redis },
27
+ ctx: {
28
+ redis: config.redis,
29
+ cache: config.ephermeralCache ? new Cache() : undefined,
30
+ },
27
31
  });
28
32
  }
29
33
  /**
@@ -70,6 +74,18 @@ export class GlobalRatelimit extends Ratelimit {
70
74
  return members
71
75
  `;
72
76
  return async function (ctx, identifier) {
77
+ if (ctx.cache) {
78
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
79
+ if (blocked) {
80
+ return {
81
+ success: false,
82
+ limit: tokens,
83
+ remaining: 0,
84
+ reset: reset,
85
+ pending: Promise.resolve(),
86
+ };
87
+ }
88
+ }
73
89
  const requestID = crypto.randomUUID();
74
90
  const bucket = Math.floor(Date.now() / windowDuration);
75
91
  const key = [identifier, bucket].join(":");
@@ -108,12 +124,17 @@ export class GlobalRatelimit extends Ratelimit {
108
124
  /**
109
125
  * Do not await sync. This should not run in the critical path.
110
126
  */
111
- sync();
127
+ const success = remaining > 0;
128
+ const reset = (bucket + 1) * windowDuration;
129
+ if (ctx.cache && !success) {
130
+ ctx.cache.blockUntil(identifier, reset);
131
+ }
112
132
  return {
113
- success: remaining > 0,
133
+ success,
114
134
  limit: tokens,
115
135
  remaining,
116
- reset: (bucket + 1) * windowDuration,
136
+ reset,
137
+ pending: sync(),
117
138
  };
118
139
  };
119
140
  }
@@ -173,6 +194,18 @@ export class GlobalRatelimit extends Ratelimit {
173
194
  `;
174
195
  const windowDuration = ms(window);
175
196
  return async function (ctx, identifier) {
197
+ if (ctx.cache) {
198
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
199
+ if (blocked) {
200
+ return {
201
+ success: false,
202
+ limit: tokens,
203
+ remaining: 0,
204
+ reset: reset,
205
+ pending: Promise.resolve(),
206
+ };
207
+ }
208
+ }
176
209
  const requestID = crypto.randomUUID();
177
210
  const now = Date.now();
178
211
  const currentWindow = Math.floor(now / windowSize);
@@ -213,15 +246,17 @@ export class GlobalRatelimit extends Ratelimit {
213
246
  await db.redis.sadd(currentKey, ...allIDs);
214
247
  }
215
248
  }
216
- /**
217
- * Do not await sync. This should not run in the critical path.
218
- */
219
- sync();
249
+ const success = remaining > 0;
250
+ const reset = (currentWindow + 1) * windowDuration;
251
+ if (ctx.cache && !success) {
252
+ ctx.cache.blockUntil(identifier, reset);
253
+ }
220
254
  return {
221
- success: remaining > 0,
255
+ success,
222
256
  limit: tokens,
223
257
  remaining,
224
- reset: (currentWindow + 1) * windowDuration,
258
+ reset,
259
+ pending: sync(),
225
260
  };
226
261
  };
227
262
  }
package/esm/ratelimit.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Cache } from "./cache.js";
1
2
  /**
2
3
  * Ratelimiter using serverless redis from https://upstash.com/
3
4
  *
@@ -92,7 +93,7 @@ export class Ratelimit {
92
93
  * An identifier per user or api.
93
94
  * Choose a userID, or api token, or ip address.
94
95
  *
95
- * If you want to globally limit your api, you can set a constant string.
96
+ * If you want to limit your api across all users, you can set a constant string.
96
97
  */
97
98
  identifier,
98
99
  /**
@@ -125,5 +126,11 @@ export class Ratelimit {
125
126
  this.ctx = config.ctx;
126
127
  this.limiter = config.limiter;
127
128
  this.prefix = config.prefix ?? "@upstash/ratelimit";
129
+ if (config.ephermeralCache instanceof Map) {
130
+ this.ctx.cache = new Cache(config.ephermeralCache);
131
+ }
132
+ else if (typeof config.ephermeralCache === "undefined") {
133
+ this.ctx.cache = new Cache(new Map());
134
+ }
128
135
  }
129
136
  }
@@ -23,7 +23,10 @@ export class RegionRatelimit extends Ratelimit {
23
23
  super({
24
24
  prefix: config.prefix,
25
25
  limiter: config.limiter,
26
- ctx: { redis: config.redis },
26
+ ctx: {
27
+ redis: config.redis,
28
+ },
29
+ ephermeralCache: config.ephermeralCache,
27
30
  });
28
31
  }
29
32
  /**
@@ -70,12 +73,30 @@ export class RegionRatelimit extends Ratelimit {
70
73
  return async function (ctx, identifier) {
71
74
  const bucket = Math.floor(Date.now() / windowDuration);
72
75
  const key = [identifier, bucket].join(":");
76
+ if (ctx.cache) {
77
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
78
+ if (blocked) {
79
+ return {
80
+ success: false,
81
+ limit: tokens,
82
+ remaining: 0,
83
+ reset: reset,
84
+ pending: Promise.resolve(),
85
+ };
86
+ }
87
+ }
73
88
  const usedTokensAfterUpdate = (await ctx.redis.eval(script, [key], [windowDuration]));
89
+ const success = usedTokensAfterUpdate <= tokens;
90
+ const reset = (bucket + 1) * windowDuration;
91
+ if (ctx.cache && !success) {
92
+ ctx.cache.blockUntil(identifier, reset);
93
+ }
74
94
  return {
75
- success: usedTokensAfterUpdate <= tokens,
95
+ success,
76
96
  limit: tokens,
77
97
  remaining: tokens - usedTokensAfterUpdate,
78
- reset: (bucket + 1) * windowDuration,
98
+ reset,
99
+ pending: Promise.resolve(),
79
100
  };
80
101
  };
81
102
  }
@@ -141,12 +162,30 @@ export class RegionRatelimit extends Ratelimit {
141
162
  const currentKey = [identifier, currentWindow].join(":");
142
163
  const previousWindow = currentWindow - windowSize;
143
164
  const previousKey = [identifier, previousWindow].join(":");
165
+ if (ctx.cache) {
166
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
167
+ if (blocked) {
168
+ return {
169
+ success: false,
170
+ limit: tokens,
171
+ remaining: 0,
172
+ reset: reset,
173
+ pending: Promise.resolve(),
174
+ };
175
+ }
176
+ }
144
177
  const remaining = (await ctx.redis.eval(script, [currentKey, previousKey], [tokens, now, windowSize]));
178
+ const success = remaining > 0;
179
+ const reset = (currentWindow + 1) * windowSize;
180
+ if (ctx.cache && !success) {
181
+ ctx.cache.blockUntil(identifier, reset);
182
+ }
145
183
  return {
146
- success: remaining > 0,
184
+ success,
147
185
  limit: tokens,
148
186
  remaining,
149
- reset: (currentWindow + 1) * windowSize,
187
+ reset,
188
+ pending: Promise.resolve(),
150
189
  };
151
190
  };
152
191
  }
@@ -223,10 +262,32 @@ export class RegionRatelimit extends Ratelimit {
223
262
  `;
224
263
  const intervalDuration = ms(interval);
225
264
  return async function (ctx, identifier) {
265
+ if (ctx.cache) {
266
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
267
+ if (blocked) {
268
+ return {
269
+ success: false,
270
+ limit: maxTokens,
271
+ remaining: 0,
272
+ reset: reset,
273
+ pending: Promise.resolve(),
274
+ };
275
+ }
276
+ }
226
277
  const now = Date.now();
227
278
  const key = [identifier, Math.floor(now / intervalDuration)].join(":");
228
279
  const [remaining, reset] = (await ctx.redis.eval(script, [key], [maxTokens, intervalDuration, refillRate, now]));
229
- return { success: remaining > 0, limit: maxTokens, remaining, reset };
280
+ const success = remaining > 0;
281
+ if (ctx.cache && !success) {
282
+ ctx.cache.blockUntil(identifier, reset);
283
+ }
284
+ return {
285
+ success,
286
+ limit: maxTokens,
287
+ remaining,
288
+ reset,
289
+ pending: Promise.resolve(),
290
+ };
230
291
  };
231
292
  }
232
293
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "./script/mod.js",
4
4
  "types": "./types/mod.d.ts",
5
5
  "name": "@upstash/ratelimit",
6
- "version": "v0.1.2",
6
+ "version": "v0.1.4-rc.0",
7
7
  "description": "A serverless ratelimiter built on top of Upstash REST API.",
8
8
  "repository": {
9
9
  "type": "git",
@@ -29,7 +29,7 @@
29
29
  "size-limit": "latest"
30
30
  },
31
31
  "peerDependencies": {
32
- "@upstash/redis": "^1.3.4"
32
+ "@upstash/redis": "^1.4.0"
33
33
  },
34
34
  "size-limit": [
35
35
  {
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Cache = void 0;
4
+ class Cache {
5
+ constructor(cache) {
6
+ /**
7
+ * Stores identifier -> reset (in milliseconds)
8
+ */
9
+ Object.defineProperty(this, "cache", {
10
+ enumerable: true,
11
+ configurable: true,
12
+ writable: true,
13
+ value: void 0
14
+ });
15
+ this.cache = cache;
16
+ }
17
+ isBlocked(identifier) {
18
+ if (!this.cache.has(identifier)) {
19
+ return { blocked: false, reset: 0 };
20
+ }
21
+ const reset = this.cache.get(identifier);
22
+ if (reset < Date.now()) {
23
+ this.cache.delete(identifier);
24
+ return { blocked: false, reset: 0 };
25
+ }
26
+ console.log(`[CACHE] isBlocked(${identifier}) -> true`);
27
+ return { blocked: true, reset: reset };
28
+ }
29
+ blockUntil(identifier, reset) {
30
+ this.cache.set(identifier, reset);
31
+ }
32
+ }
33
+ exports.Cache = Cache;
package/script/mod.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GlobalRatelimit = exports.Ratelimit = void 0;
4
- var region_js_1 = require("./region.js");
5
- Object.defineProperty(exports, "Ratelimit", { enumerable: true, get: function () { return region_js_1.RegionRatelimit; } });
6
- var global_js_1 = require("./global.js");
7
- Object.defineProperty(exports, "GlobalRatelimit", { enumerable: true, get: function () { return global_js_1.GlobalRatelimit; } });
3
+ exports.MultiRegionRatelimit = exports.Ratelimit = void 0;
4
+ var single_js_1 = require("./single.js");
5
+ Object.defineProperty(exports, "Ratelimit", { enumerable: true, get: function () { return single_js_1.RegionRatelimit; } });
6
+ var multi_js_1 = require("./multi.js");
7
+ Object.defineProperty(exports, "MultiRegionRatelimit", { enumerable: true, get: function () { return multi_js_1.MultiRegionRatelimit; } });
@@ -1,16 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.GlobalRatelimit = void 0;
3
+ exports.MultiRegionRatelimit = void 0;
4
4
  const duration_js_1 = require("./duration.js");
5
5
  const ratelimit_js_1 = require("./ratelimit.js");
6
+ const cache_js_1 = require("./cache.js");
6
7
  /**
7
8
  * Ratelimiter using serverless redis from https://upstash.com/
8
9
  *
9
10
  * @example
10
11
  * ```ts
11
- * const { limit } = new GlobalRatelimit({
12
+ * const { limit } = new MultiRegionRatelimit({
12
13
  * redis: Redis.fromEnv(),
13
- * limiter: GlobalRatelimit.fixedWindow(
14
+ * limiter: MultiRegionRatelimit.fixedWindow(
14
15
  * 10, // Allow 10 requests per window of 30 minutes
15
16
  * "30 m", // interval of 30 minutes
16
17
  * )
@@ -18,7 +19,7 @@ const ratelimit_js_1 = require("./ratelimit.js");
18
19
  *
19
20
  * ```
20
21
  */
21
- class GlobalRatelimit extends ratelimit_js_1.Ratelimit {
22
+ class MultiRegionRatelimit extends ratelimit_js_1.Ratelimit {
22
23
  /**
23
24
  * Create a new Ratelimit instance by providing a `@upstash/redis` instance and the algorithn of your choice.
24
25
  */
@@ -26,7 +27,10 @@ class GlobalRatelimit extends ratelimit_js_1.Ratelimit {
26
27
  super({
27
28
  prefix: config.prefix,
28
29
  limiter: config.limiter,
29
- ctx: { redis: config.redis },
30
+ ctx: {
31
+ redis: config.redis,
32
+ cache: config.ephermeralCache ? new cache_js_1.Cache() : undefined,
33
+ },
30
34
  });
31
35
  }
32
36
  /**
@@ -73,6 +77,18 @@ class GlobalRatelimit extends ratelimit_js_1.Ratelimit {
73
77
  return members
74
78
  `;
75
79
  return async function (ctx, identifier) {
80
+ if (ctx.cache) {
81
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
82
+ if (blocked) {
83
+ return {
84
+ success: false,
85
+ limit: tokens,
86
+ remaining: 0,
87
+ reset: reset,
88
+ pending: Promise.resolve(),
89
+ };
90
+ }
91
+ }
76
92
  const requestID = crypto.randomUUID();
77
93
  const bucket = Math.floor(Date.now() / windowDuration);
78
94
  const key = [identifier, bucket].join(":");
@@ -111,12 +127,17 @@ class GlobalRatelimit extends ratelimit_js_1.Ratelimit {
111
127
  /**
112
128
  * Do not await sync. This should not run in the critical path.
113
129
  */
114
- sync();
130
+ const success = remaining > 0;
131
+ const reset = (bucket + 1) * windowDuration;
132
+ if (ctx.cache && !success) {
133
+ ctx.cache.blockUntil(identifier, reset);
134
+ }
115
135
  return {
116
- success: remaining > 0,
136
+ success,
117
137
  limit: tokens,
118
138
  remaining,
119
- reset: (bucket + 1) * windowDuration,
139
+ reset,
140
+ pending: sync(),
120
141
  };
121
142
  };
122
143
  }
@@ -176,6 +197,18 @@ class GlobalRatelimit extends ratelimit_js_1.Ratelimit {
176
197
  `;
177
198
  const windowDuration = (0, duration_js_1.ms)(window);
178
199
  return async function (ctx, identifier) {
200
+ if (ctx.cache) {
201
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
202
+ if (blocked) {
203
+ return {
204
+ success: false,
205
+ limit: tokens,
206
+ remaining: 0,
207
+ reset: reset,
208
+ pending: Promise.resolve(),
209
+ };
210
+ }
211
+ }
179
212
  const requestID = crypto.randomUUID();
180
213
  const now = Date.now();
181
214
  const currentWindow = Math.floor(now / windowSize);
@@ -216,17 +249,19 @@ class GlobalRatelimit extends ratelimit_js_1.Ratelimit {
216
249
  await db.redis.sadd(currentKey, ...allIDs);
217
250
  }
218
251
  }
219
- /**
220
- * Do not await sync. This should not run in the critical path.
221
- */
222
- sync();
252
+ const success = remaining > 0;
253
+ const reset = (currentWindow + 1) * windowDuration;
254
+ if (ctx.cache && !success) {
255
+ ctx.cache.blockUntil(identifier, reset);
256
+ }
223
257
  return {
224
- success: remaining > 0,
258
+ success,
225
259
  limit: tokens,
226
260
  remaining,
227
- reset: (currentWindow + 1) * windowDuration,
261
+ reset,
262
+ pending: sync(),
228
263
  };
229
264
  };
230
265
  }
231
266
  }
232
- exports.GlobalRatelimit = GlobalRatelimit;
267
+ exports.MultiRegionRatelimit = MultiRegionRatelimit;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Ratelimit = void 0;
4
+ const cache_js_1 = require("./cache.js");
4
5
  /**
5
6
  * Ratelimiter using serverless redis from https://upstash.com/
6
7
  *
@@ -95,7 +96,7 @@ class Ratelimit {
95
96
  * An identifier per user or api.
96
97
  * Choose a userID, or api token, or ip address.
97
98
  *
98
- * If you want to globally limit your api, you can set a constant string.
99
+ * If you want to limit your api across all users, you can set a constant string.
99
100
  */
100
101
  identifier,
101
102
  /**
@@ -128,6 +129,12 @@ class Ratelimit {
128
129
  this.ctx = config.ctx;
129
130
  this.limiter = config.limiter;
130
131
  this.prefix = config.prefix ?? "@upstash/ratelimit";
132
+ if (config.ephermeralCache instanceof Map) {
133
+ this.ctx.cache = new cache_js_1.Cache(config.ephermeralCache);
134
+ }
135
+ else if (typeof config.ephermeralCache === "undefined") {
136
+ this.ctx.cache = new cache_js_1.Cache(new Map());
137
+ }
131
138
  }
132
139
  }
133
140
  exports.Ratelimit = Ratelimit;
@@ -26,7 +26,10 @@ class RegionRatelimit extends ratelimit_js_1.Ratelimit {
26
26
  super({
27
27
  prefix: config.prefix,
28
28
  limiter: config.limiter,
29
- ctx: { redis: config.redis },
29
+ ctx: {
30
+ redis: config.redis,
31
+ },
32
+ ephermeralCache: config.ephermeralCache,
30
33
  });
31
34
  }
32
35
  /**
@@ -73,12 +76,30 @@ class RegionRatelimit extends ratelimit_js_1.Ratelimit {
73
76
  return async function (ctx, identifier) {
74
77
  const bucket = Math.floor(Date.now() / windowDuration);
75
78
  const key = [identifier, bucket].join(":");
79
+ if (ctx.cache) {
80
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
81
+ if (blocked) {
82
+ return {
83
+ success: false,
84
+ limit: tokens,
85
+ remaining: 0,
86
+ reset: reset,
87
+ pending: Promise.resolve(),
88
+ };
89
+ }
90
+ }
76
91
  const usedTokensAfterUpdate = (await ctx.redis.eval(script, [key], [windowDuration]));
92
+ const success = usedTokensAfterUpdate <= tokens;
93
+ const reset = (bucket + 1) * windowDuration;
94
+ if (ctx.cache && !success) {
95
+ ctx.cache.blockUntil(identifier, reset);
96
+ }
77
97
  return {
78
- success: usedTokensAfterUpdate <= tokens,
98
+ success,
79
99
  limit: tokens,
80
100
  remaining: tokens - usedTokensAfterUpdate,
81
- reset: (bucket + 1) * windowDuration,
101
+ reset,
102
+ pending: Promise.resolve(),
82
103
  };
83
104
  };
84
105
  }
@@ -144,12 +165,30 @@ class RegionRatelimit extends ratelimit_js_1.Ratelimit {
144
165
  const currentKey = [identifier, currentWindow].join(":");
145
166
  const previousWindow = currentWindow - windowSize;
146
167
  const previousKey = [identifier, previousWindow].join(":");
168
+ if (ctx.cache) {
169
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
170
+ if (blocked) {
171
+ return {
172
+ success: false,
173
+ limit: tokens,
174
+ remaining: 0,
175
+ reset: reset,
176
+ pending: Promise.resolve(),
177
+ };
178
+ }
179
+ }
147
180
  const remaining = (await ctx.redis.eval(script, [currentKey, previousKey], [tokens, now, windowSize]));
181
+ const success = remaining > 0;
182
+ const reset = (currentWindow + 1) * windowSize;
183
+ if (ctx.cache && !success) {
184
+ ctx.cache.blockUntil(identifier, reset);
185
+ }
148
186
  return {
149
- success: remaining > 0,
187
+ success,
150
188
  limit: tokens,
151
189
  remaining,
152
- reset: (currentWindow + 1) * windowSize,
190
+ reset,
191
+ pending: Promise.resolve(),
153
192
  };
154
193
  };
155
194
  }
@@ -226,10 +265,32 @@ class RegionRatelimit extends ratelimit_js_1.Ratelimit {
226
265
  `;
227
266
  const intervalDuration = (0, duration_js_1.ms)(interval);
228
267
  return async function (ctx, identifier) {
268
+ if (ctx.cache) {
269
+ const { blocked, reset } = ctx.cache.isBlocked(identifier);
270
+ if (blocked) {
271
+ return {
272
+ success: false,
273
+ limit: maxTokens,
274
+ remaining: 0,
275
+ reset: reset,
276
+ pending: Promise.resolve(),
277
+ };
278
+ }
279
+ }
229
280
  const now = Date.now();
230
281
  const key = [identifier, Math.floor(now / intervalDuration)].join(":");
231
282
  const [remaining, reset] = (await ctx.redis.eval(script, [key], [maxTokens, intervalDuration, refillRate, now]));
232
- return { success: remaining > 0, limit: maxTokens, remaining, reset };
283
+ const success = remaining > 0;
284
+ if (ctx.cache && !success) {
285
+ ctx.cache.blockUntil(identifier, reset);
286
+ }
287
+ return {
288
+ success,
289
+ limit: maxTokens,
290
+ remaining,
291
+ reset,
292
+ pending: Promise.resolve(),
293
+ };
233
294
  };
234
295
  }
235
296
  }
@@ -0,0 +1,13 @@
1
+ import { EphermeralCache } from "./types.js";
2
+ export declare class Cache implements EphermeralCache {
3
+ /**
4
+ * Stores identifier -> reset (in milliseconds)
5
+ */
6
+ private readonly cache;
7
+ constructor(cache: Map<string, number>);
8
+ isBlocked(identifier: string): {
9
+ blocked: boolean;
10
+ reset: number;
11
+ };
12
+ blockUntil(identifier: string, reset: number): void;
13
+ }
package/types/mod.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { RegionRatelimit as Ratelimit } from "./region.js";
2
- export type { RegionRatelimitConfig as RatelimitConfig } from "./region.js";
3
- export { GlobalRatelimit } from "./global.js";
4
- export type { GlobalRatelimitConfig } from "./global.js";
1
+ export { RegionRatelimit as Ratelimit } from "./single.js";
2
+ export type { RegionRatelimitConfig as RatelimitConfig } from "./single.js";
3
+ export { MultiRegionRatelimit } from "./multi.js";
4
+ export type { MultiRegionRatelimitConfig } from "./multi.js";
5
5
  export type { Algorithm } from "./types.js";
@@ -1,8 +1,8 @@
1
1
  import type { Duration } from "./duration.js";
2
- import type { Algorithm, GlobalContext } from "./types.js";
2
+ import type { Algorithm, MultiRegionContext } from "./types.js";
3
3
  import { Ratelimit } from "./ratelimit.js";
4
4
  import type { Redis } from "./types.js";
5
- export declare type GlobalRatelimitConfig = {
5
+ export declare type MultiRegionRatelimitConfig = {
6
6
  /**
7
7
  * Instances of `@upstash/redis`
8
8
  * @see https://github.com/upstash/upstash-redis#quick-start
@@ -13,24 +13,41 @@ export declare type GlobalRatelimitConfig = {
13
13
  *
14
14
  * Choose one of the predefined ones or implement your own.
15
15
  * Available algorithms are exposed via static methods:
16
- * - GlobalRatelimit.fixedWindow
16
+ * - MultiRegionRatelimit.fixedWindow
17
17
  */
18
- limiter: Algorithm<GlobalContext>;
18
+ limiter: Algorithm<MultiRegionContext>;
19
19
  /**
20
20
  * All keys in redis are prefixed with this.
21
21
  *
22
22
  * @default `@upstash/ratelimit`
23
23
  */
24
24
  prefix?: string;
25
+ /**
26
+ * If enabled, the ratelimiter will keep a global cache of identifiers, that have
27
+ * exhausted their ratelimit. In serverless environments this is only possible if
28
+ * you create the ratelimiter instance outside of your handler function. While the
29
+ * function is still hot, the ratelimiter can block requests without having to
30
+ * request data from redis, thus saving time and money.
31
+ *
32
+ * Whenever an identifier has exceeded its limit, the ratelimiter will add it to an
33
+ * internal list together with its reset timestamp. If the same identifier makes a
34
+ * new request before it is reset, we can immediately reject it.
35
+ *
36
+ * Set to `false` to disable.
37
+ *
38
+ * If left undefined, a map is created automatically, but it can only work
39
+ * if the map or th ratelimit instance is created outside your serverless function handler.
40
+ */
41
+ ephermeralCache?: Map<string, number> | false;
25
42
  };
26
43
  /**
27
44
  * Ratelimiter using serverless redis from https://upstash.com/
28
45
  *
29
46
  * @example
30
47
  * ```ts
31
- * const { limit } = new GlobalRatelimit({
48
+ * const { limit } = new MultiRegionRatelimit({
32
49
  * redis: Redis.fromEnv(),
33
- * limiter: GlobalRatelimit.fixedWindow(
50
+ * limiter: MultiRegionRatelimit.fixedWindow(
34
51
  * 10, // Allow 10 requests per window of 30 minutes
35
52
  * "30 m", // interval of 30 minutes
36
53
  * )
@@ -38,11 +55,11 @@ export declare type GlobalRatelimitConfig = {
38
55
  *
39
56
  * ```
40
57
  */
41
- export declare class GlobalRatelimit extends Ratelimit<GlobalContext> {
58
+ export declare class MultiRegionRatelimit extends Ratelimit<MultiRegionContext> {
42
59
  /**
43
60
  * Create a new Ratelimit instance by providing a `@upstash/redis` instance and the algorithn of your choice.
44
61
  */
45
- constructor(config: GlobalRatelimitConfig);
62
+ constructor(config: MultiRegionRatelimitConfig);
46
63
  /**
47
64
  * Each requests inside a fixed time increases a counter.
48
65
  * Once the counter reaches a maxmimum allowed number, all further requests are
@@ -69,7 +86,7 @@ export declare class GlobalRatelimit extends Ratelimit<GlobalContext> {
69
86
  /**
70
87
  * The duration in which `tokens` requests are allowed.
71
88
  */
72
- window: Duration): Algorithm<GlobalContext>;
89
+ window: Duration): Algorithm<MultiRegionContext>;
73
90
  /**
74
91
  * Combined approach of `slidingLogs` and `fixedWindow` with lower storage
75
92
  * costs than `slidingLogs` and improved boundary behavior by calcualting a
@@ -94,5 +111,5 @@ export declare class GlobalRatelimit extends Ratelimit<GlobalContext> {
94
111
  /**
95
112
  * The duration in which `tokens` requests are allowed.
96
113
  */
97
- window: Duration): Algorithm<GlobalContext>;
114
+ window: Duration): Algorithm<MultiRegionContext>;
98
115
  }
@@ -18,6 +18,23 @@ export declare type RatelimitConfig<TContext> = {
18
18
  * @default `@upstash/ratelimit`
19
19
  */
20
20
  prefix?: string;
21
+ /**
22
+ * If enabled, the ratelimiter will keep a global cache of identifiers, that have
23
+ * exhausted their ratelimit. In serverless environments this is only possible if
24
+ * you create the ratelimiter instance outside of your handler function. While the
25
+ * function is still hot, the ratelimiter can block requests without having to
26
+ * request data from redis, thus saving time and money.
27
+ *
28
+ * Whenever an identifier has exceeded its limit, the ratelimiter will add it to an
29
+ * internal list together with its reset timestamp. If the same identifier makes a
30
+ * new request before it is reset, we can immediately reject it.
31
+ *
32
+ * Set to `false` to disable.
33
+ *
34
+ * If left undefined, a map is created automatically, but it can only work
35
+ * if the map or the ratelimit instance is created outside your serverless function handler.
36
+ */
37
+ ephermeralCache?: Map<string, number> | false;
21
38
  };
22
39
  /**
23
40
  * Ratelimiter using serverless redis from https://upstash.com/
@@ -25,6 +25,23 @@ export declare type RegionRatelimitConfig = {
25
25
  * @default `@upstash/ratelimit`
26
26
  */
27
27
  prefix?: string;
28
+ /**
29
+ * If enabled, the ratelimiter will keep a global cache of identifiers, that have
30
+ * exhausted their ratelimit. In serverless environments this is only possible if
31
+ * you create the ratelimiter instance outside of your handler function. While the
32
+ * function is still hot, the ratelimiter can block requests without having to
33
+ * request data from redis, thus saving time and money.
34
+ *
35
+ * Whenever an identifier has exceeded its limit, the ratelimiter will add it to an
36
+ * internal list together with its reset timestamp. If the same identifier makes a
37
+ * new request before it is reset, we can immediately reject it.
38
+ *
39
+ * Set to `false` to disable.
40
+ *
41
+ * If left undefined, a map is created automatically, but it can only work
42
+ * if the map or the ratelimit instance is created outside your serverless function handler.
43
+ */
44
+ ephermeralCache?: Map<string, number> | false;
28
45
  };
29
46
  /**
30
47
  * Ratelimiter using serverless redis from https://upstash.com/
package/types/types.d.ts CHANGED
@@ -2,13 +2,25 @@ export interface Redis {
2
2
  eval: (script: string, keys: string[], values: unknown[]) => Promise<unknown>;
3
3
  sadd: (key: string, ...members: string[]) => Promise<number>;
4
4
  }
5
+ /**
6
+ * EphermeralCache is used to block certain identifiers right away in case they have already exceedd the ratelimit.
7
+ */
8
+ export interface EphermeralCache {
9
+ isBlocked: (identifier: string) => {
10
+ blocked: boolean;
11
+ reset: number;
12
+ };
13
+ blockUntil: (identifier: string, reset: number) => void;
14
+ }
5
15
  export declare type RegionContext = {
6
16
  redis: Redis;
17
+ cache?: EphermeralCache;
7
18
  };
8
- export declare type GlobalContext = {
19
+ export declare type MultiRegionContext = {
9
20
  redis: Redis[];
21
+ cache?: EphermeralCache;
10
22
  };
11
- export declare type Context = RegionContext | GlobalContext;
23
+ export declare type Context = RegionContext | MultiRegionContext;
12
24
  export declare type RatelimitResponse = {
13
25
  /**
14
26
  * Whether the request may pass(true) or exceeded the limit(false)
@@ -26,5 +38,30 @@ export declare type RatelimitResponse = {
26
38
  * Unix timestamp in milliseconds when the limits are reset.
27
39
  */
28
40
  reset: number;
41
+ /**
42
+ * For the MultiRegion setup we do some synchronizing in the background, after returning the current limit.
43
+ * In most case you can simply ignore this.
44
+ *
45
+ * On Vercel Edge or Cloudflare workers, you need to explicitely handle the pending Promise like this:
46
+ *
47
+ * **Vercel Edge:**
48
+ * https://nextjs.org/docs/api-reference/next/server#nextfetchevent
49
+ *
50
+ * ```ts
51
+ * const { pending } = await ratelimit.limit("id")
52
+ * event.waitUntil(pending)
53
+ * ```
54
+ *
55
+ * **Cloudflare Worker:**
56
+ * https://developers.cloudflare.com/workers/runtime-apis/fetch-event/#syntax-module-worker
57
+ *
58
+ * ```ts
59
+ * const { pending } = await ratelimit.limit("id")
60
+ * context.waitUntil(pending)
61
+ * ```
62
+ */
63
+ pending: Promise<unknown>;
29
64
  };
30
- export declare type Algorithm<TContext> = (ctx: TContext, identifier: string) => Promise<RatelimitResponse>;
65
+ export declare type Algorithm<TContext> = (ctx: TContext, identifier: string, opts?: {
66
+ cache?: EphermeralCache;
67
+ }) => Promise<RatelimitResponse>;