limitly 1.0.2 → 3.0.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/README.md +471 -22
- package/dist/algorithms/concurrency.d.ts +11 -0
- package/dist/algorithms/concurrency.d.ts.map +1 -0
- package/dist/algorithms/concurrency.js +19 -0
- package/dist/algorithms/concurrency.js.map +1 -0
- package/dist/algorithms/factory.d.ts +3 -2
- package/dist/algorithms/factory.d.ts.map +1 -1
- package/dist/algorithms/factory.js +9 -3
- package/dist/algorithms/factory.js.map +1 -1
- package/dist/algorithms/gcra.d.ts +10 -0
- package/dist/algorithms/gcra.d.ts.map +1 -0
- package/dist/algorithms/gcra.js +15 -0
- package/dist/algorithms/gcra.js.map +1 -0
- package/dist/algorithms/sliding-window.d.ts +4 -4
- package/dist/algorithms/sliding-window.d.ts.map +1 -1
- package/dist/algorithms/sliding-window.js +3 -23
- package/dist/algorithms/sliding-window.js.map +1 -1
- package/dist/algorithms/token-bucket.d.ts +4 -4
- package/dist/algorithms/token-bucket.d.ts.map +1 -1
- package/dist/algorithms/token-bucket.js +3 -20
- package/dist/algorithms/token-bucket.js.map +1 -1
- package/dist/index.d.ts +19 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +46 -1
- package/dist/index.js.map +1 -1
- package/dist/limiter.d.ts +23 -7
- package/dist/limiter.d.ts.map +1 -1
- package/dist/limiter.js +86 -16
- package/dist/limiter.js.map +1 -1
- package/dist/middleware/bun.d.ts +9 -0
- package/dist/middleware/bun.d.ts.map +1 -0
- package/dist/middleware/bun.js +107 -0
- package/dist/middleware/bun.js.map +1 -0
- package/dist/middleware/express.d.ts.map +1 -1
- package/dist/middleware/express.js +30 -14
- package/dist/middleware/express.js.map +1 -1
- package/dist/middleware/fastify.d.ts +3 -3
- package/dist/middleware/fastify.d.ts.map +1 -1
- package/dist/middleware/fastify.js +34 -17
- package/dist/middleware/fastify.js.map +1 -1
- package/dist/middleware/hono.d.ts +5 -0
- package/dist/middleware/hono.d.ts.map +1 -0
- package/dist/middleware/hono.js +71 -0
- package/dist/middleware/hono.js.map +1 -0
- package/dist/middleware/koa.d.ts +5 -0
- package/dist/middleware/koa.d.ts.map +1 -0
- package/dist/middleware/koa.js +65 -0
- package/dist/middleware/koa.js.map +1 -0
- package/dist/middleware/nest.d.ts +14 -0
- package/dist/middleware/nest.d.ts.map +1 -0
- package/dist/middleware/nest.js +112 -0
- package/dist/middleware/nest.js.map +1 -0
- package/dist/observability/index.d.ts +4 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +10 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/opentelemetry.d.ts +28 -0
- package/dist/observability/opentelemetry.d.ts.map +1 -0
- package/dist/observability/opentelemetry.js +85 -0
- package/dist/observability/opentelemetry.js.map +1 -0
- package/dist/observability/prometheus.d.ts +27 -0
- package/dist/observability/prometheus.d.ts.map +1 -0
- package/dist/observability/prometheus.js +88 -0
- package/dist/observability/prometheus.js.map +1 -0
- package/dist/observability/types.d.ts +9 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +3 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/prometheus.d.ts +3 -0
- package/dist/prometheus.d.ts.map +1 -0
- package/dist/prometheus.js +9 -0
- package/dist/prometheus.js.map +1 -0
- package/dist/scripts/concurrencyAcquire.lua +34 -0
- package/dist/scripts/concurrencyRelease.lua +9 -0
- package/dist/scripts/gcra.lua +38 -0
- package/dist/stores/factory.d.ts +5 -0
- package/dist/stores/factory.d.ts.map +1 -0
- package/dist/stores/factory.js +40 -0
- package/dist/stores/factory.js.map +1 -0
- package/dist/stores/memcached-store.d.ts +16 -0
- package/dist/stores/memcached-store.d.ts.map +1 -0
- package/dist/stores/memcached-store.js +211 -0
- package/dist/stores/memcached-store.js.map +1 -0
- package/dist/stores/redis-store.d.ts +19 -0
- package/dist/stores/redis-store.d.ts.map +1 -0
- package/dist/stores/redis-store.js +97 -0
- package/dist/stores/redis-store.js.map +1 -0
- package/dist/stores/types.d.ts +11 -0
- package/dist/stores/types.d.ts.map +1 -0
- package/dist/stores/types.js +3 -0
- package/dist/stores/types.js.map +1 -0
- package/dist/types/index.d.ts +94 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/defaults.d.ts +8 -0
- package/dist/utils/defaults.d.ts.map +1 -0
- package/dist/utils/defaults.js +100 -0
- package/dist/utils/defaults.js.map +1 -0
- package/dist/utils/limit-execution.d.ts +40 -0
- package/dist/utils/limit-execution.d.ts.map +1 -0
- package/dist/utils/limit-execution.js +50 -0
- package/dist/utils/limit-execution.js.map +1 -0
- package/dist/utils/memcached.d.ts +12 -0
- package/dist/utils/memcached.d.ts.map +1 -0
- package/dist/utils/memcached.js +103 -0
- package/dist/utils/memcached.js.map +1 -0
- package/dist/utils/metrics.d.ts +21 -0
- package/dist/utils/metrics.d.ts.map +1 -0
- package/dist/utils/metrics.js +99 -0
- package/dist/utils/metrics.js.map +1 -0
- package/dist/utils/redis.d.ts +5 -1
- package/dist/utils/redis.d.ts.map +1 -1
- package/dist/utils/redis.js +38 -3
- package/dist/utils/redis.js.map +1 -1
- package/dist/utils/scripts.d.ts +16 -2
- package/dist/utils/scripts.d.ts.map +1 -1
- package/dist/utils/scripts.js +79 -33
- package/dist/utils/scripts.js.map +1 -1
- package/package.json +86 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# limitly
|
|
2
2
|
|
|
3
|
-
Distributed, Redis-powered rate limiting for Express and
|
|
3
|
+
Distributed, Redis-powered rate limiting for Express, Fastify, Hono, Koa, Bun, and NestJS.
|
|
4
4
|
|
|
5
5
|
> Express-rate-limit, but distributed, Redis-powered, and production ready.
|
|
6
6
|
|
|
@@ -8,6 +8,28 @@ Distributed, Redis-powered rate limiting for Express and Fastify.
|
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
npm install limitly ioredis
|
|
11
|
+
|
|
12
|
+
# Memcached backend
|
|
13
|
+
npm install limitly memcached
|
|
14
|
+
|
|
15
|
+
# OpenTelemetry
|
|
16
|
+
npm install limitly ioredis @opentelemetry/api
|
|
17
|
+
|
|
18
|
+
# Prometheus
|
|
19
|
+
npm install limitly ioredis prom-client
|
|
20
|
+
|
|
21
|
+
# NestJS
|
|
22
|
+
npm install limitly ioredis @nestjs/common @nestjs/core
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Framework-specific subpath imports are available for tree-shaking:
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { createExpressMiddleware } from "limitly/express";
|
|
29
|
+
import { createFastifyPlugin } from "limitly/fastify";
|
|
30
|
+
import { createHonoMiddleware } from "limitly/hono";
|
|
31
|
+
import { createKoaMiddleware } from "limitly/koa";
|
|
32
|
+
import { createBunMiddleware } from "limitly/bun";
|
|
11
33
|
```
|
|
12
34
|
|
|
13
35
|
## Quick Start
|
|
@@ -23,14 +45,7 @@ const app = express();
|
|
|
23
45
|
const redis = new Redis();
|
|
24
46
|
const limiter = createLimiter({ redis });
|
|
25
47
|
|
|
26
|
-
app.use(
|
|
27
|
-
limiter.middleware({
|
|
28
|
-
algorithm: "sliding-window",
|
|
29
|
-
limit: 100,
|
|
30
|
-
window: 60,
|
|
31
|
-
key: (req) => req.ip,
|
|
32
|
-
}),
|
|
33
|
-
);
|
|
48
|
+
app.use(limiter.middleware({ key: (req) => req.ip }));
|
|
34
49
|
```
|
|
35
50
|
|
|
36
51
|
### Fastify
|
|
@@ -51,11 +66,179 @@ await fastify.register(limiter.fastifyPlugin, {
|
|
|
51
66
|
});
|
|
52
67
|
```
|
|
53
68
|
|
|
69
|
+
### Hono
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
import { Hono } from "hono";
|
|
73
|
+
import Redis from "ioredis";
|
|
74
|
+
import { createLimiter } from "limitly";
|
|
75
|
+
|
|
76
|
+
const app = new Hono();
|
|
77
|
+
const limiter = createLimiter({ redis: new Redis() });
|
|
78
|
+
|
|
79
|
+
app.use("*", limiter.honoMiddleware({ key: (c) => c.req.header("x-api-key") }));
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Koa
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
import Koa from "koa";
|
|
86
|
+
import Redis from "ioredis";
|
|
87
|
+
import { createLimiter } from "limitly";
|
|
88
|
+
|
|
89
|
+
const app = new Koa();
|
|
90
|
+
const limiter = createLimiter({ redis: new Redis() });
|
|
91
|
+
|
|
92
|
+
app.use(limiter.koaMiddleware({ key: (ctx) => ctx.ip }));
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Bun
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import Redis from "ioredis";
|
|
99
|
+
import { composeBunHandler, createLimiter } from "limitly";
|
|
100
|
+
|
|
101
|
+
const limiter = createLimiter({ redis: new Redis() });
|
|
102
|
+
|
|
103
|
+
const rateLimit = limiter.bunMiddleware({
|
|
104
|
+
key: (req) => req.headers.get("x-api-key"),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const fetch = composeBunHandler(
|
|
108
|
+
[rateLimit],
|
|
109
|
+
() => Response.json({ message: "Hello Bun!" })
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
Bun.serve({ port: 3000, fetch });
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### NestJS
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { Module } from "@nestjs/common";
|
|
119
|
+
import { APP_GUARD } from "@nestjs/core";
|
|
120
|
+
import Redis from "ioredis";
|
|
121
|
+
import { createLimiter } from "limitly";
|
|
122
|
+
import { RateLimit } from "limitly/nest";
|
|
123
|
+
|
|
124
|
+
const limiter = createLimiter({ redis: new Redis() });
|
|
125
|
+
const NestGuard = limiter.nestGuard({
|
|
126
|
+
limit: 100,
|
|
127
|
+
window: 60,
|
|
128
|
+
key: (req) => req.ip,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
@Module({
|
|
132
|
+
providers: [{ provide: APP_GUARD, useClass: NestGuard }],
|
|
133
|
+
})
|
|
134
|
+
export class AppModule {}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Per-route limits with `@RateLimit()`:
|
|
138
|
+
|
|
139
|
+
```typescript
|
|
140
|
+
import { Controller, Get } from "@nestjs/common";
|
|
141
|
+
import { RateLimit } from "limitly/nest";
|
|
142
|
+
|
|
143
|
+
@Controller("api")
|
|
144
|
+
export class ApiController {
|
|
145
|
+
@Get()
|
|
146
|
+
@RateLimit({ limit: 10, window: 60 })
|
|
147
|
+
findAll() {
|
|
148
|
+
return { ok: true };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Module helper (like `redisLimitPlugin` for Fastify):
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { limitlyNestModule } from "limitly/nest";
|
|
157
|
+
|
|
158
|
+
@Module({
|
|
159
|
+
imports: [
|
|
160
|
+
limitlyNestModule({
|
|
161
|
+
limiter,
|
|
162
|
+
algorithm: "token-bucket",
|
|
163
|
+
capacity: 50,
|
|
164
|
+
refillRate: 10,
|
|
165
|
+
global: true,
|
|
166
|
+
}),
|
|
167
|
+
],
|
|
168
|
+
})
|
|
169
|
+
export class AppModule {}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Default Algorithm
|
|
173
|
+
|
|
174
|
+
If you omit algorithm options, limitly uses **GCRA** with `limit: 100` and `window: 60`:
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
const limiter = createLimiter({ redis: new Redis() });
|
|
178
|
+
|
|
179
|
+
app.use(limiter.middleware({ key: (req) => req.ip }));
|
|
180
|
+
|
|
181
|
+
// equivalent to:
|
|
182
|
+
app.use(
|
|
183
|
+
limiter.middleware({
|
|
184
|
+
algorithm: "gcra",
|
|
185
|
+
limit: 100,
|
|
186
|
+
window: 60,
|
|
187
|
+
key: (req) => req.ip,
|
|
188
|
+
})
|
|
189
|
+
);
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Set limiter-wide defaults:
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const limiter = createLimiter({
|
|
196
|
+
redis: new Redis(),
|
|
197
|
+
default: { limit: 50, window: 30 },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.use(limiter.middleware({ key: (req) => req.ip }));
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Programmatic Checks
|
|
204
|
+
|
|
205
|
+
Use `limiter.check()` outside middleware — useful for login guards, background jobs, or custom response handling:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
const result = await limiter.check(req.ip ?? "unknown", {
|
|
209
|
+
limit: 5,
|
|
210
|
+
window: 10,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
if (!result.allowed) {
|
|
214
|
+
return res.status(429).json({
|
|
215
|
+
error: "Too many attempts",
|
|
216
|
+
retryAfter: result.retryAfter,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
`check()` respects limiter-wide defaults, `failOpen`, and global `onMetrics` hooks.
|
|
222
|
+
|
|
54
223
|
## Algorithms
|
|
55
224
|
|
|
225
|
+
### GCRA (default)
|
|
226
|
+
|
|
227
|
+
Generic Cell Rate Algorithm — smooth rate limiting with controlled bursts. Uses a single TAT (theoretical arrival time) per key, so it's memory-efficient compared to sliding window:
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
limiter.middleware({
|
|
231
|
+
limit: 100,
|
|
232
|
+
window: 60, // seconds — algorithm defaults to gcra
|
|
233
|
+
key: (req) => req.ip,
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
GCRA enforces an average rate of `limit / window` while allowing short bursts up to `limit`. Ideal when you want token-bucket-like behavior with predictable storage costs.
|
|
238
|
+
|
|
56
239
|
### Sliding Window
|
|
57
240
|
|
|
58
|
-
Uses Redis Sorted Sets for
|
|
241
|
+
Uses Redis Sorted Sets (or Memcached counters) for rate limiting over a rolling time window.
|
|
59
242
|
|
|
60
243
|
```typescript
|
|
61
244
|
limiter.middleware({
|
|
@@ -77,30 +260,117 @@ limiter.middleware({
|
|
|
77
260
|
});
|
|
78
261
|
```
|
|
79
262
|
|
|
80
|
-
|
|
263
|
+
### Concurrency
|
|
264
|
+
|
|
265
|
+
Limits simultaneous in-flight requests per key. Slots are acquired on entry and released when the response finishes (middleware handles this automatically):
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
limiter.middleware({
|
|
269
|
+
algorithm: "concurrency",
|
|
270
|
+
limit: 10, // max concurrent requests
|
|
271
|
+
ttl: 300, // lease TTL in seconds for stale slot cleanup
|
|
272
|
+
key: (req) => req.ip,
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For manual acquire/release outside middleware:
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
const acquired = await limiter.acquire("job-42", {
|
|
280
|
+
algorithm: "concurrency",
|
|
281
|
+
limit: 5,
|
|
282
|
+
ttl: 120,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
if (!acquired.allowed) {
|
|
286
|
+
throw new Error("Too many concurrent jobs");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
try {
|
|
290
|
+
await runJob();
|
|
291
|
+
} finally {
|
|
292
|
+
await limiter.release("job-42", acquired.slotId!, {
|
|
293
|
+
algorithm: "concurrency",
|
|
294
|
+
limit: 5,
|
|
295
|
+
ttl: 120,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Storage keys use the `cc:` prefix: `{keyPrefix}:cc:{id}`.
|
|
301
|
+
|
|
302
|
+
## Storage Backends
|
|
303
|
+
|
|
304
|
+
limitly supports Redis, Valkey, DragonflyDB (Redis-compatible), and Memcached.
|
|
81
305
|
|
|
82
|
-
|
|
306
|
+
### Redis / Valkey / DragonflyDB
|
|
307
|
+
|
|
308
|
+
Redis-compatible backends use atomic Lua scripts (sorted sets + hashes). Connect with `ioredis`:
|
|
83
309
|
|
|
84
310
|
```typescript
|
|
85
|
-
|
|
311
|
+
import Redis from "ioredis";
|
|
312
|
+
|
|
313
|
+
// Redis
|
|
86
314
|
createLimiter({ redis: new Redis() });
|
|
87
315
|
|
|
88
|
-
//
|
|
89
|
-
createLimiter({ redis: "redis://localhost:6379" });
|
|
316
|
+
// Valkey
|
|
317
|
+
createLimiter({ store: "valkey", redis: "redis://localhost:6379" });
|
|
90
318
|
|
|
91
|
-
//
|
|
92
|
-
createLimiter({ redis: { host: "localhost", port: 6379 } });
|
|
319
|
+
// DragonflyDB
|
|
320
|
+
createLimiter({ store: "dragonfly", redis: { host: "localhost", port: 6379 } });
|
|
93
321
|
|
|
94
|
-
// Cluster
|
|
322
|
+
// Cluster (auto-pipelining, script warmup, master reads)
|
|
95
323
|
createLimiter({
|
|
324
|
+
store: "redis",
|
|
96
325
|
redis: {
|
|
97
|
-
nodes: [
|
|
326
|
+
nodes: [
|
|
327
|
+
{ host: "127.0.0.1", port: 7000 },
|
|
328
|
+
{ host: "127.0.0.1", port: 7001 },
|
|
329
|
+
],
|
|
330
|
+
options: {
|
|
331
|
+
redisOptions: { password: "secret" },
|
|
332
|
+
},
|
|
98
333
|
},
|
|
99
334
|
});
|
|
335
|
+
|
|
336
|
+
// Optional: pin all keys to one slot (use only when you need co-location)
|
|
337
|
+
createLimiter({
|
|
338
|
+
redis: { nodes: [{ host: "127.0.0.1", port: 7000 }] },
|
|
339
|
+
hashTag: "limitly",
|
|
340
|
+
});
|
|
100
341
|
```
|
|
101
342
|
|
|
343
|
+
Cluster optimizations built into limitly:
|
|
344
|
+
|
|
345
|
+
- **ioredis `defineCommand`** — Lua scripts are registered cluster-aware, avoiding per-node `NOSCRIPT` fallbacks
|
|
346
|
+
- **Script warmup** — scripts are preloaded on all master nodes at startup (disable with `warmupScripts: false`)
|
|
347
|
+
- **Auto-pipelining** — enabled by default for higher throughput under concurrent load
|
|
348
|
+
- **Hash tags** — optional `hashTag` for slot pinning; leave unset to spread keys across slots (recommended)
|
|
349
|
+
- **Master reads** — `scaleReads: "master"` by default so checks always hit the authoritative node
|
|
350
|
+
|
|
351
|
+
### Memcached
|
|
352
|
+
|
|
353
|
+
Memcached uses counter-based sliding window and CAS token bucket (no Lua required):
|
|
354
|
+
|
|
355
|
+
```typescript
|
|
356
|
+
import Memcached from "memcached";
|
|
357
|
+
|
|
358
|
+
createLimiter({
|
|
359
|
+
store: "memcached",
|
|
360
|
+
memcached: "localhost:11211",
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Or pass an existing client
|
|
364
|
+
createLimiter({ memcached: new Memcached(["localhost:11211"]) });
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
> Memcached sliding window uses a weighted two-window counter (high accuracy, no sorted sets).
|
|
368
|
+
> Token bucket uses `gets`/`cas` for atomic updates.
|
|
369
|
+
|
|
102
370
|
## Key Extraction
|
|
103
371
|
|
|
372
|
+
The `key` option identifies **who** is being rate limited (per request):
|
|
373
|
+
|
|
104
374
|
```typescript
|
|
105
375
|
// IP-based
|
|
106
376
|
key: (req) => req.ip;
|
|
@@ -112,6 +382,31 @@ key: (req) => req.headers["x-api-key"];
|
|
|
112
382
|
key: (req) => req.user.id;
|
|
113
383
|
```
|
|
114
384
|
|
|
385
|
+
## Storage Key Prefix
|
|
386
|
+
|
|
387
|
+
The `keyPrefix` option controls **where** counters are stored in Redis/Memcached.
|
|
388
|
+
Default is `limitly`:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
createLimiter({ redis: new Redis() });
|
|
392
|
+
// stores keys like: limitly:sw:203.0.113.1
|
|
393
|
+
|
|
394
|
+
createLimiter({
|
|
395
|
+
redis: new Redis(),
|
|
396
|
+
keyPrefix: "myapp:prod",
|
|
397
|
+
});
|
|
398
|
+
// stores keys like: myapp:prod:sw:203.0.113.1
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
Key format:
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
{keyPrefix}:sw:{id} — sliding window
|
|
405
|
+
{keyPrefix}:tb:{id} — token bucket
|
|
406
|
+
{keyPrefix}:gcra:{id} — GCRA
|
|
407
|
+
{keyPrefix}:cc:{id} — concurrency
|
|
408
|
+
```
|
|
409
|
+
|
|
115
410
|
## Response Headers
|
|
116
411
|
|
|
117
412
|
Standard rate limit headers are set by default:
|
|
@@ -129,15 +424,166 @@ Disable with `headers: false`.
|
|
|
129
424
|
|
|
130
425
|
```typescript
|
|
131
426
|
limiter.middleware({
|
|
132
|
-
algorithm: "sliding-window",
|
|
133
|
-
limit: 100,
|
|
134
|
-
window: 60,
|
|
135
427
|
onLimitReached(req, res) {
|
|
136
428
|
res.status(429).json({ code: "RATE_LIMITED" });
|
|
137
429
|
},
|
|
138
430
|
});
|
|
139
431
|
```
|
|
140
432
|
|
|
433
|
+
## Metrics
|
|
434
|
+
|
|
435
|
+
Emit observability events via the `onMetrics` hook. Set it globally on the limiter or per middleware/route:
|
|
436
|
+
|
|
437
|
+
```typescript
|
|
438
|
+
const limiter = createLimiter({
|
|
439
|
+
redis: new Redis(),
|
|
440
|
+
onMetrics: (event) => {
|
|
441
|
+
console.log(event.type, event.key, `${event.durationMs.toFixed(2)}ms`);
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Per-route override
|
|
446
|
+
limiter.middleware({
|
|
447
|
+
onMetrics: (event) => metrics.increment(`ratelimit.${event.type}`),
|
|
448
|
+
});
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
Event types:
|
|
452
|
+
|
|
453
|
+
| Type | When |
|
|
454
|
+
|------|------|
|
|
455
|
+
| `allowed` | Request passed the rate limit check |
|
|
456
|
+
| `blocked` | Request exceeded the limit |
|
|
457
|
+
| `error` | Store operation failed |
|
|
458
|
+
| `fail_open` | Store failed but `failOpen: true` allowed traffic through |
|
|
459
|
+
|
|
460
|
+
Each event includes `key`, `algorithm`, `durationMs`, and optionally `store`, `context` (the request object), and `result` or `error` depending on type.
|
|
461
|
+
|
|
462
|
+
Multiple hooks are supported:
|
|
463
|
+
|
|
464
|
+
```typescript
|
|
465
|
+
onMetrics: [logToConsole, sendToDatadog]
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
`limiter.check()` and all framework middleware use the same metrics pipeline.
|
|
469
|
+
|
|
470
|
+
## OpenTelemetry
|
|
471
|
+
|
|
472
|
+
Use the `limitly/otel` helpers to emit OTEL metrics and traces. Only `@opentelemetry/api` is required — bring your own SDK/exporter:
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
import Redis from "ioredis";
|
|
476
|
+
import { createLimiter } from "limitly";
|
|
477
|
+
import { createOpenTelemetryInstrumentation } from "limitly/otel";
|
|
478
|
+
|
|
479
|
+
const otel = createOpenTelemetryInstrumentation();
|
|
480
|
+
|
|
481
|
+
const limiter = createLimiter({
|
|
482
|
+
redis: new Redis(),
|
|
483
|
+
tracer: otel.tracer,
|
|
484
|
+
onMetrics: otel.onMetrics,
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
app.use(limiter.middleware({ key: (req) => req.ip }));
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`createOpenTelemetryInstrumentation()` returns:
|
|
491
|
+
|
|
492
|
+
- **`tracer`** — creates `limitly.check` spans with `limitly.algorithm`, `limitly.store`, `limitly.outcome`, `limitly.limit`, and `limitly.remaining` attributes
|
|
493
|
+
- **`onMetrics`** — records `limitly.check.total` (counter) and `limitly.check.duration` (histogram, ms)
|
|
494
|
+
|
|
495
|
+
Use the pieces independently when needed:
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
import {
|
|
499
|
+
createOpenTelemetryMetricsHook,
|
|
500
|
+
createOpenTelemetryTracer,
|
|
501
|
+
} from "limitly/otel";
|
|
502
|
+
|
|
503
|
+
const limiter = createLimiter({
|
|
504
|
+
redis: new Redis(),
|
|
505
|
+
tracer: createOpenTelemetryTracer(),
|
|
506
|
+
onMetrics: createOpenTelemetryMetricsHook({ includeKey: false }),
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
Combine with custom hooks:
|
|
511
|
+
|
|
512
|
+
```typescript
|
|
513
|
+
onMetrics: [otel.onMetrics, customHook]
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
## Prometheus
|
|
517
|
+
|
|
518
|
+
Use `limitly/prometheus` to expose rate limit metrics for scraping:
|
|
519
|
+
|
|
520
|
+
```typescript
|
|
521
|
+
import express from "express";
|
|
522
|
+
import Redis from "ioredis";
|
|
523
|
+
import { createLimiter } from "limitly";
|
|
524
|
+
import {
|
|
525
|
+
createPrometheusExporter,
|
|
526
|
+
createPrometheusHandler,
|
|
527
|
+
} from "limitly/prometheus";
|
|
528
|
+
|
|
529
|
+
const app = express();
|
|
530
|
+
const prometheus = createPrometheusExporter();
|
|
531
|
+
|
|
532
|
+
const limiter = createLimiter({
|
|
533
|
+
redis: new Redis(),
|
|
534
|
+
onMetrics: prometheus.onMetrics,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
app.use(limiter.middleware({ key: (req) => req.ip }));
|
|
538
|
+
app.get("/metrics", createPrometheusHandler(prometheus));
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Metrics exposed:
|
|
542
|
+
|
|
543
|
+
| Metric | Type | Labels |
|
|
544
|
+
|--------|------|--------|
|
|
545
|
+
| `limitly_check_total` | Counter | `algorithm`, `outcome`, `store` |
|
|
546
|
+
| `limitly_check_duration_seconds` | Histogram | `algorithm`, `outcome`, `store` |
|
|
547
|
+
|
|
548
|
+
`outcome` is one of `allowed`, `blocked`, `error`, or `fail_open`.
|
|
549
|
+
|
|
550
|
+
Options:
|
|
551
|
+
|
|
552
|
+
| Option | Default | Description |
|
|
553
|
+
|--------|---------|-------------|
|
|
554
|
+
| `register` | new `Registry` | Prometheus registry |
|
|
555
|
+
| `prefix` | `"limitly"` | Metric name prefix |
|
|
556
|
+
| `includeKey` | `false` | Add rate limit key as a label (high cardinality) |
|
|
557
|
+
| `durationBuckets` | `[0.001, 0.005, 0.01, …, 1]` | Histogram buckets (seconds) |
|
|
558
|
+
|
|
559
|
+
Use a shared registry with your existing metrics:
|
|
560
|
+
|
|
561
|
+
```typescript
|
|
562
|
+
import { Registry } from "prom-client";
|
|
563
|
+
|
|
564
|
+
const register = new Registry();
|
|
565
|
+
const prometheus = createPrometheusExporter({ register });
|
|
566
|
+
|
|
567
|
+
onMetrics: [prometheus.onMetrics, otherHook];
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Scrape manually without HTTP middleware:
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
const body = await prometheus.getMetrics();
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
Use `createPrometheusMetricsHook` when you only need the `onMetrics` hook without the HTTP handler:
|
|
577
|
+
|
|
578
|
+
```typescript
|
|
579
|
+
import { createPrometheusMetricsHook } from "limitly/prometheus";
|
|
580
|
+
|
|
581
|
+
const limiter = createLimiter({
|
|
582
|
+
redis: new Redis(),
|
|
583
|
+
onMetrics: createPrometheusMetricsHook({ prefix: "api" }),
|
|
584
|
+
});
|
|
585
|
+
```
|
|
586
|
+
|
|
141
587
|
## Fail Open / Closed
|
|
142
588
|
|
|
143
589
|
When Redis is unavailable:
|
|
@@ -157,9 +603,12 @@ Implement custom algorithms with the `RateLimitStrategy` interface:
|
|
|
157
603
|
```typescript
|
|
158
604
|
interface RateLimitStrategy {
|
|
159
605
|
consume(key: string): Promise<RateLimitResult>;
|
|
606
|
+
release?(key: string, slotId: string): Promise<void>;
|
|
160
607
|
}
|
|
161
608
|
```
|
|
162
609
|
|
|
610
|
+
`release` is optional — only needed for concurrency-style strategies that hold a slot until the response finishes.
|
|
611
|
+
|
|
163
612
|
## Performance
|
|
164
613
|
|
|
165
614
|
- Atomic operations via Lua scripts (`EVALSHA`)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ConcurrencyConfig, RateLimitResult, RateLimitStrategy } from "../types";
|
|
2
|
+
import type { RateLimitStore } from "../stores/types";
|
|
3
|
+
export declare class ConcurrencyStrategy implements RateLimitStrategy {
|
|
4
|
+
private readonly store;
|
|
5
|
+
private readonly limit;
|
|
6
|
+
private readonly ttl;
|
|
7
|
+
constructor(store: RateLimitStore, config: ConcurrencyConfig);
|
|
8
|
+
consume(key: string): Promise<RateLimitResult>;
|
|
9
|
+
release(key: string, slotId: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=concurrency.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.d.ts","sourceRoot":"","sources":["../../src/algorithms/concurrency.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,iBAAiB,EACjB,eAAe,EACf,iBAAiB,EAClB,MAAM,UAAU,CAAC;AAClB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAGtD,qBAAa,mBAAoB,YAAW,iBAAiB;IAC3D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;gBAEjB,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,iBAAiB;IAMtD,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;IAI9C,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAG1D"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ConcurrencyStrategy = void 0;
|
|
4
|
+
const defaults_1 = require("../utils/defaults");
|
|
5
|
+
class ConcurrencyStrategy {
|
|
6
|
+
constructor(store, config) {
|
|
7
|
+
this.store = store;
|
|
8
|
+
this.limit = config.limit;
|
|
9
|
+
this.ttl = config.ttl ?? defaults_1.DEFAULT_CONCURRENCY.ttl;
|
|
10
|
+
}
|
|
11
|
+
async consume(key) {
|
|
12
|
+
return this.store.concurrencyAcquire(key, this.limit, this.ttl);
|
|
13
|
+
}
|
|
14
|
+
async release(key, slotId) {
|
|
15
|
+
await this.store.concurrencyRelease(key, slotId);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.ConcurrencyStrategy = ConcurrencyStrategy;
|
|
19
|
+
//# sourceMappingURL=concurrency.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"concurrency.js","sourceRoot":"","sources":["../../src/algorithms/concurrency.ts"],"names":[],"mappings":";;;AAMA,gDAAwD;AAExD,MAAa,mBAAmB;IAK9B,YAAY,KAAqB,EAAE,MAAyB;QAC1D,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,GAAG,GAAG,MAAM,CAAC,GAAG,IAAI,8BAAmB,CAAC,GAAI,CAAC;IACpD,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;IAClE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,MAAc;QACvC,MAAM,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACnD,CAAC;CACF;AAlBD,kDAkBC"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
import type { AlgorithmConfig, RateLimitStrategy
|
|
2
|
-
|
|
1
|
+
import type { AlgorithmConfig, RateLimitStrategy } from "../types";
|
|
2
|
+
import type { RateLimitStore } from "../stores/types";
|
|
3
|
+
export declare function createStrategy(store: RateLimitStore, config: AlgorithmConfig): RateLimitStrategy;
|
|
3
4
|
//# sourceMappingURL=factory.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"factory.d.ts","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AACnE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAMtD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,cAAc,EACrB,MAAM,EAAE,eAAe,GACtB,iBAAiB,CAiBnB"}
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.createStrategy = createStrategy;
|
|
4
|
+
const concurrency_1 = require("./concurrency");
|
|
5
|
+
const gcra_1 = require("./gcra");
|
|
4
6
|
const sliding_window_1 = require("./sliding-window");
|
|
5
7
|
const token_bucket_1 = require("./token-bucket");
|
|
6
|
-
function createStrategy(
|
|
8
|
+
function createStrategy(store, config) {
|
|
7
9
|
switch (config.algorithm) {
|
|
8
10
|
case "sliding-window":
|
|
9
|
-
return new sliding_window_1.SlidingWindowStrategy(
|
|
11
|
+
return new sliding_window_1.SlidingWindowStrategy(store, config);
|
|
10
12
|
case "token-bucket":
|
|
11
|
-
return new token_bucket_1.TokenBucketStrategy(
|
|
13
|
+
return new token_bucket_1.TokenBucketStrategy(store, config);
|
|
14
|
+
case "concurrency":
|
|
15
|
+
return new concurrency_1.ConcurrencyStrategy(store, config);
|
|
16
|
+
case "gcra":
|
|
17
|
+
return new gcra_1.GcraStrategy(store, config);
|
|
12
18
|
default: {
|
|
13
19
|
const exhaustive = config;
|
|
14
20
|
throw new Error(`Unknown algorithm: ${exhaustive.algorithm}`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"factory.js","sourceRoot":"","sources":["../../src/algorithms/factory.ts"],"names":[],"mappings":";;AAOA,wCAoBC;AAzBD,+CAAoD;AACpD,iCAAsC;AACtC,qDAAyD;AACzD,iDAAqD;AAErD,SAAgB,cAAc,CAC5B,KAAqB,EACrB,MAAuB;IAEvB,QAAQ,MAAM,CAAC,SAAS,EAAE,CAAC;QACzB,KAAK,gBAAgB;YACnB,OAAO,IAAI,sCAAqB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAClD,KAAK,cAAc;YACjB,OAAO,IAAI,kCAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAChD,KAAK,aAAa;YAChB,OAAO,IAAI,iCAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QAChD,KAAK,MAAM;YACT,OAAO,IAAI,mBAAY,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC;QACzC,OAAO,CAAC,CAAC,CAAC;YACR,MAAM,UAAU,GAAU,MAAM,CAAC;YACjC,MAAM,IAAI,KAAK,CACb,sBAAuB,UAA8B,CAAC,SAAS,EAAE,CAClE,CAAC;QACJ,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { GcraConfig, RateLimitResult, RateLimitStrategy } from "../types";
|
|
2
|
+
import type { RateLimitStore } from "../stores/types";
|
|
3
|
+
export declare class GcraStrategy implements RateLimitStrategy {
|
|
4
|
+
private readonly store;
|
|
5
|
+
private readonly limit;
|
|
6
|
+
private readonly window;
|
|
7
|
+
constructor(store: RateLimitStore, config: GcraConfig);
|
|
8
|
+
consume(key: string): Promise<RateLimitResult>;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=gcra.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gcra.d.ts","sourceRoot":"","sources":["../../src/algorithms/gcra.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,UAAU,CAAC;AAC/E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,qBAAa,YAAa,YAAW,iBAAiB;IACpD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAiB;IACvC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;gBAEpB,KAAK,EAAE,cAAc,EAAE,MAAM,EAAE,UAAU;IAM/C,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,CAAC;CAGrD"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GcraStrategy = void 0;
|
|
4
|
+
class GcraStrategy {
|
|
5
|
+
constructor(store, config) {
|
|
6
|
+
this.store = store;
|
|
7
|
+
this.limit = config.limit;
|
|
8
|
+
this.window = config.window;
|
|
9
|
+
}
|
|
10
|
+
async consume(key) {
|
|
11
|
+
return this.store.gcra(key, this.limit, this.window);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
exports.GcraStrategy = GcraStrategy;
|
|
15
|
+
//# sourceMappingURL=gcra.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gcra.js","sourceRoot":"","sources":["../../src/algorithms/gcra.ts"],"names":[],"mappings":";;;AAGA,MAAa,YAAY;IAKvB,YAAY,KAAqB,EAAE,MAAkB;QACnD,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IAC9B,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW;QACvB,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,MAAM,CAAC,CAAC;IACvD,CAAC;CACF;AAdD,oCAcC"}
|