halt-rate 0.2.0 → 0.4.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 +34 -921
- package/dist/adapters/express.d.mts +1 -1
- package/dist/adapters/express.d.ts +1 -1
- package/dist/adapters/fastify.d.mts +29 -0
- package/dist/adapters/fastify.d.ts +29 -0
- package/dist/adapters/fastify.js +38 -0
- package/dist/adapters/fastify.js.map +1 -0
- package/dist/adapters/fastify.mjs +35 -0
- package/dist/adapters/fastify.mjs.map +1 -0
- package/dist/adapters/graphql.d.mts +22 -0
- package/dist/adapters/graphql.d.ts +22 -0
- package/dist/adapters/graphql.js +51 -0
- package/dist/adapters/graphql.js.map +1 -0
- package/dist/adapters/graphql.mjs +49 -0
- package/dist/adapters/graphql.mjs.map +1 -0
- package/dist/adapters/hono.d.mts +30 -0
- package/dist/adapters/hono.d.ts +30 -0
- package/dist/adapters/hono.js +56 -0
- package/dist/adapters/hono.js.map +1 -0
- package/dist/adapters/hono.mjs +54 -0
- package/dist/adapters/hono.mjs.map +1 -0
- package/dist/adapters/next.d.mts +1 -1
- package/dist/adapters/next.d.ts +1 -1
- package/dist/adapters/next.js +26 -3
- package/dist/adapters/next.js.map +1 -1
- package/dist/adapters/next.mjs +26 -3
- package/dist/adapters/next.mjs.map +1 -1
- package/dist/index.d.mts +174 -3
- package/dist/index.d.ts +174 -3
- package/dist/index.js +631 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +614 -4
- package/dist/index.mjs.map +1 -1
- package/dist/limiter-DqVVE0Kl.d.mts +310 -0
- package/dist/limiter-DqVVE0Kl.d.ts +310 -0
- package/package.json +80 -13
- package/dist/limiter-HCt8ADZ2.d.mts +0 -177
- package/dist/limiter-HCt8ADZ2.d.ts +0 -177
package/README.md
CHANGED
|
@@ -1,949 +1,62 @@
|
|
|
1
|
-
# Halt TypeScript SDK
|
|
1
|
+
# Halt — TypeScript SDK
|
|
2
2
|
|
|
3
|
-
**
|
|
3
|
+
**SaaS-aware, cross-language rate limiting.** Per-user / per-API-key / per-plan limits, quotas, weighted endpoints, abuse controls, atomic Redis accuracy, and built-in observability — with a matching [Python package](https://pypi.org/project/halt-rate/).
|
|
4
4
|
|
|
5
|
+
[](https://www.npmjs.com/package/halt-rate)
|
|
5
6
|
[](https://opensource.org/licenses/MIT)
|
|
6
|
-
[](https://www.typescriptlang.org/)
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
🚀 **Four Rate Limiting Algorithms**
|
|
11
|
-
- Token Bucket (burst-friendly, recommended)
|
|
12
|
-
- Fixed Window (simple, fast)
|
|
13
|
-
- Sliding Window (accurate, memory-intensive)
|
|
14
|
-
- Leaky Bucket (traffic shaping, constant rate)
|
|
15
|
-
|
|
16
|
-
💾 **Multiple Storage Backends**
|
|
17
|
-
- In-Memory (development, single-threaded)
|
|
18
|
-
- Redis (production, distributed, **atomic via Lua**, cluster-safe) ✅
|
|
19
|
-
- PostgreSQL (ACID, relational)
|
|
20
|
-
- MongoDB (document store, TTL indexes)
|
|
21
|
-
- DynamoDB (AWS serverless, auto-scaling)
|
|
22
|
-
- Memcached (distributed cache, fast)
|
|
23
|
-
|
|
24
|
-
🎯 **SaaS-Ready Features**
|
|
25
|
-
- Plan-based rate limiting (FREE, STARTER, PRO, BUSINESS, ENTERPRISE)
|
|
26
|
-
- Quota management (hourly, daily, monthly, yearly)
|
|
27
|
-
- Penalty system (abuse detection, progressive penalties)
|
|
28
|
-
- Telemetry hooks (logging, metrics, observability)
|
|
29
|
-
|
|
30
|
-
🔧 **Framework Support**
|
|
31
|
-
- Express
|
|
32
|
-
- Next.js (App Router & Pages Router)
|
|
33
|
-
- Next.js Middleware
|
|
34
|
-
|
|
35
|
-
✨ **Smart Features**
|
|
36
|
-
- Automatic health check exemptions
|
|
37
|
-
- Private IP exemptions
|
|
38
|
-
- Custom exemption lists
|
|
39
|
-
- Weighted endpoints (cost-based limiting)
|
|
40
|
-
- Per-request algorithm override
|
|
41
|
-
- Standard rate limit headers (RateLimit-*, Retry-After)
|
|
42
|
-
|
|
43
|
-
---
|
|
44
|
-
|
|
45
|
-
## Installation
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
npm install halt
|
|
49
|
-
# or
|
|
50
|
-
yarn add halt
|
|
51
|
-
# or
|
|
52
|
-
pnpm add halt
|
|
53
|
-
```
|
|
8
|
+
📖 **Full documentation: [halt.afroawi.com](https://halt.afroawi.com)**
|
|
54
9
|
|
|
55
|
-
|
|
10
|
+
## Install
|
|
56
11
|
|
|
57
12
|
```bash
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
# PostgreSQL support
|
|
62
|
-
npm install pg
|
|
63
|
-
|
|
64
|
-
# MongoDB support
|
|
65
|
-
npm install mongodb
|
|
66
|
-
|
|
67
|
-
# DynamoDB support
|
|
68
|
-
npm install @aws-sdk/client-dynamodb @aws-sdk/util-dynamodb
|
|
69
|
-
|
|
70
|
-
# Memcached support
|
|
71
|
-
npm install memcached
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
---
|
|
75
|
-
|
|
76
|
-
## Storage Backends
|
|
77
|
-
|
|
78
|
-
### In-Memory (Development)
|
|
79
|
-
|
|
80
|
-
```typescript
|
|
81
|
-
import { InMemoryStore } from 'halt';
|
|
82
|
-
|
|
83
|
-
const store = new InMemoryStore();
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
### Redis (Production, recommended)
|
|
87
|
-
|
|
88
|
-
The Redis store runs the full check-and-consume **inside Redis via a Lua script**,
|
|
89
|
-
so limits stay accurate under concurrent load from any number of app servers. You
|
|
90
|
-
inject your own client (ioredis or node-redis v4+), so Halt has no hard dependency
|
|
91
|
-
on a particular Redis library.
|
|
92
|
-
|
|
93
|
-
```typescript
|
|
94
|
-
import Redis from 'ioredis';
|
|
95
|
-
import { RateLimiter, RedisStore, presets } from 'halt';
|
|
96
|
-
|
|
97
|
-
const store = new RedisStore({
|
|
98
|
-
client: new Redis(process.env.REDIS_URL),
|
|
99
|
-
// What to do if Redis is unreachable:
|
|
100
|
-
// 'open' (default) — allow the request (don't take down traffic)
|
|
101
|
-
// 'closed' — block the request (429)
|
|
102
|
-
failMode: 'open',
|
|
103
|
-
onError: (err) => console.error('redis rate-limit error', err),
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
const limiter = new RateLimiter({ store, policy: presets.PUBLIC_API });
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
**Atomicity.** All four algorithms are implemented as single-key Lua scripts, so a
|
|
110
|
-
burst of concurrent requests can never over-admit past the limit. The script reads
|
|
111
|
-
the clock from the Redis server (`TIME`), so app-server clock skew doesn't matter.
|
|
112
|
-
|
|
113
|
-
**Redis Cluster.** Every script touches exactly one key, so it is cluster-safe out
|
|
114
|
-
of the box. To colocate related keys on the same slot, wrap the variable part of
|
|
115
|
-
your key in a hash tag, e.g. `halt:{user-123}:...`.
|
|
116
|
-
|
|
117
|
-
> JavaScript has no synchronous network I/O, so the TypeScript store is async-only
|
|
118
|
-
> (the limiter's `check()` is already async). A synchronous client is available in
|
|
119
|
-
> the Python package.
|
|
120
|
-
|
|
121
|
-
### PostgreSQL
|
|
122
|
-
|
|
123
|
-
```typescript
|
|
124
|
-
import { PostgresStore } from 'halt/stores/postgres';
|
|
125
|
-
|
|
126
|
-
const store = new PostgresStore({
|
|
127
|
-
host: 'localhost',
|
|
128
|
-
port: 5432,
|
|
129
|
-
database: 'mydb',
|
|
130
|
-
user: 'user',
|
|
131
|
-
password: 'password',
|
|
132
|
-
tableName: 'rate_limits', // optional
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### MongoDB
|
|
137
|
-
|
|
138
|
-
```typescript
|
|
139
|
-
import { MongoDBStore } from 'halt/stores/mongodb';
|
|
140
|
-
|
|
141
|
-
const store = new MongoDBStore({
|
|
142
|
-
connectionString: 'mongodb://localhost:27017',
|
|
143
|
-
database: 'halt',
|
|
144
|
-
collection: 'rate_limits',
|
|
145
|
-
});
|
|
146
|
-
```
|
|
147
|
-
|
|
148
|
-
### DynamoDB
|
|
149
|
-
|
|
150
|
-
```typescript
|
|
151
|
-
import { DynamoDBStore } from 'halt/stores/dynamodb';
|
|
152
|
-
|
|
153
|
-
const store = new DynamoDBStore({
|
|
154
|
-
tableName: 'rate_limits',
|
|
155
|
-
region: 'us-east-1',
|
|
156
|
-
});
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Memcached
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
import { MemcachedStore } from 'halt/stores/memcached';
|
|
163
|
-
|
|
164
|
-
const store = new MemcachedStore({
|
|
165
|
-
servers: 'localhost:11211',
|
|
166
|
-
});
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
---
|
|
170
|
-
|
|
171
|
-
## SaaS Features
|
|
172
|
-
|
|
173
|
-
### Plan-Based Rate Limiting
|
|
174
|
-
|
|
175
|
-
```typescript
|
|
176
|
-
import { getPlanPolicy, PLAN_FREE, PLAN_PRO, PLAN_ENTERPRISE } from 'halt';
|
|
177
|
-
|
|
178
|
-
// Use plan-based presets
|
|
179
|
-
const freePolicy = PLAN_FREE; // 100 req/hour
|
|
180
|
-
const proPolicy = PLAN_PRO; // 2000 req/hour
|
|
181
|
-
const enterprisePolicy = PLAN_ENTERPRISE; // 20000 req/hour
|
|
182
|
-
|
|
183
|
-
// Get policy by plan name
|
|
184
|
-
const policy = getPlanPolicy('pro');
|
|
185
|
-
|
|
186
|
-
// Dynamic policy resolution
|
|
187
|
-
function getUserPolicy(user: User) {
|
|
188
|
-
return getPlanPolicy(user.plan);
|
|
189
|
-
}
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
### Quota Management
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
import { QuotaManager, Quota, QuotaPeriod } from 'halt/core/quota';
|
|
196
|
-
|
|
197
|
-
const quotaManager = new QuotaManager(store);
|
|
198
|
-
|
|
199
|
-
const monthlyQuota: Quota = {
|
|
200
|
-
name: 'api_calls',
|
|
201
|
-
limit: 100000,
|
|
202
|
-
period: QuotaPeriod.MONTHLY,
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
// Check quota
|
|
206
|
-
const { allowed, quota: currentQuota } = await quotaManager.checkQuota(
|
|
207
|
-
'user_123',
|
|
208
|
-
monthlyQuota
|
|
209
|
-
);
|
|
210
|
-
|
|
211
|
-
if (allowed) {
|
|
212
|
-
// Consume quota
|
|
213
|
-
await quotaManager.consumeQuota('user_123', monthlyQuota, 1);
|
|
214
|
-
} else {
|
|
215
|
-
console.log(`Quota exceeded. Resets at: ${currentQuota.resetAt}`);
|
|
216
|
-
}
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### Penalty System
|
|
220
|
-
|
|
221
|
-
```typescript
|
|
222
|
-
import { PenaltyManager, PENALTY_MODERATE } from 'halt/core/penalty';
|
|
223
|
-
|
|
224
|
-
const penaltyManager = new PenaltyManager(store, PENALTY_MODERATE);
|
|
225
|
-
|
|
226
|
-
// Record violation
|
|
227
|
-
const penalty = await penaltyManager.recordViolation('user_123', 1.0);
|
|
228
|
-
|
|
229
|
-
// Check penalty status
|
|
230
|
-
if (penaltyManager.isActive(penalty)) {
|
|
231
|
-
console.log(`User penalized until: ${penalty.penaltyUntil}`);
|
|
232
|
-
console.log(`Abuse score: ${penalty.abuseScore}`);
|
|
233
|
-
}
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
### Telemetry & Observability
|
|
237
|
-
|
|
238
|
-
```typescript
|
|
239
|
-
import { LoggingTelemetry, MetricsTelemetry, CompositeTelemetry } from 'halt/core/telemetry';
|
|
240
|
-
|
|
241
|
-
// Logging telemetry
|
|
242
|
-
const telemetry = new LoggingTelemetry(console);
|
|
243
|
-
|
|
244
|
-
// Metrics telemetry (with your metrics client)
|
|
245
|
-
class CustomMetrics {
|
|
246
|
-
increment(metric: string, tags?: any) { /* ... */ }
|
|
247
|
-
gauge(metric: string, value: number, tags?: any) { /* ... */ }
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const metricsTelemetry = new MetricsTelemetry(new CustomMetrics());
|
|
251
|
-
|
|
252
|
-
// Combine multiple telemetry hooks
|
|
253
|
-
const compositeTelemetry = new CompositeTelemetry([
|
|
254
|
-
new LoggingTelemetry(console),
|
|
255
|
-
metricsTelemetry,
|
|
256
|
-
]);
|
|
257
|
-
|
|
258
|
-
// Use with limiter
|
|
259
|
-
const limiter = new RateLimiter({
|
|
260
|
-
store,
|
|
261
|
-
policy,
|
|
262
|
-
telemetry: compositeTelemetry,
|
|
263
|
-
});
|
|
264
|
-
```
|
|
265
|
-
|
|
266
|
-
---
|
|
267
|
-
|
|
268
|
-
## Quick Start
|
|
269
|
-
|
|
270
|
-
### Express
|
|
271
|
-
|
|
272
|
-
```typescript
|
|
273
|
-
import express from 'express';
|
|
274
|
-
import { RateLimiter, InMemoryStore, presets } from 'halt';
|
|
275
|
-
import { haltMiddleware } from 'halt/express';
|
|
276
|
-
|
|
277
|
-
const app = express();
|
|
278
|
-
|
|
279
|
-
const limiter = new RateLimiter({
|
|
280
|
-
store: new InMemoryStore(),
|
|
281
|
-
policy: presets.PUBLIC_API, // 100 req/min
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
app.use(haltMiddleware({ limiter }));
|
|
285
|
-
|
|
286
|
-
app.get('/', (req, res) => {
|
|
287
|
-
res.json({ message: 'Hello World' });
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
app.listen(3000);
|
|
291
|
-
```
|
|
292
|
-
|
|
293
|
-
### Next.js App Router
|
|
294
|
-
|
|
295
|
-
```typescript
|
|
296
|
-
// app/api/data/route.ts
|
|
297
|
-
import { withHalt } from 'halt/next';
|
|
298
|
-
import { InMemoryStore, presets } from 'halt';
|
|
299
|
-
|
|
300
|
-
const store = new InMemoryStore();
|
|
301
|
-
|
|
302
|
-
async function handler(req: Request) {
|
|
303
|
-
return Response.json({ message: 'Hello World' });
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
export const GET = withHalt(handler, {
|
|
307
|
-
store,
|
|
308
|
-
policy: presets.PUBLIC_API,
|
|
309
|
-
});
|
|
310
|
-
```
|
|
311
|
-
|
|
312
|
-
### Next.js Middleware
|
|
313
|
-
|
|
314
|
-
```typescript
|
|
315
|
-
// middleware.ts
|
|
316
|
-
import { haltMiddleware } from 'halt/next';
|
|
317
|
-
import { InMemoryStore, presets } from 'halt';
|
|
318
|
-
|
|
319
|
-
export default haltMiddleware({
|
|
320
|
-
store: new InMemoryStore(),
|
|
321
|
-
policy: presets.PUBLIC_API,
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
export const config = {
|
|
325
|
-
matcher: '/api/:path*',
|
|
326
|
-
};
|
|
327
|
-
```
|
|
328
|
-
|
|
329
|
-
---
|
|
330
|
-
|
|
331
|
-
## Preset Policies
|
|
332
|
-
|
|
333
|
-
Halt comes with battle-tested presets:
|
|
334
|
-
|
|
335
|
-
```typescript
|
|
336
|
-
import { presets } from 'halt';
|
|
337
|
-
|
|
338
|
-
// Public API - moderate limits
|
|
339
|
-
presets.PUBLIC_API
|
|
340
|
-
// 100 requests/minute, burst: 120
|
|
341
|
-
|
|
342
|
-
// Authentication endpoints - strict
|
|
343
|
-
presets.AUTH_ENDPOINTS
|
|
344
|
-
// 5 requests/minute, burst: 10, 5min cooldown
|
|
345
|
-
|
|
346
|
-
// Expensive operations - very strict
|
|
347
|
-
presets.EXPENSIVE_OPS
|
|
348
|
-
// 10 requests/hour, burst: 15, cost: 10
|
|
349
|
-
|
|
350
|
-
// Strict API - for sensitive ops
|
|
351
|
-
presets.STRICT_API
|
|
352
|
-
// 20 requests/minute, burst: 25
|
|
353
|
-
|
|
354
|
-
// Generous API - for internal services
|
|
355
|
-
presets.GENEROUS_API
|
|
356
|
-
// 1000 requests/minute, burst: 1200
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
---
|
|
360
|
-
|
|
361
|
-
## Custom Policies
|
|
362
|
-
|
|
363
|
-
### Basic Custom Policy
|
|
364
|
-
|
|
365
|
-
```typescript
|
|
366
|
-
import { Policy, KeyStrategy, Algorithm } from 'halt';
|
|
367
|
-
|
|
368
|
-
const customPolicy: Policy = {
|
|
369
|
-
name: 'custom',
|
|
370
|
-
limit: 50,
|
|
371
|
-
window: 60, // 1 minute
|
|
372
|
-
burst: 60,
|
|
373
|
-
algorithm: Algorithm.TOKEN_BUCKET,
|
|
374
|
-
keyStrategy: KeyStrategy.IP,
|
|
375
|
-
};
|
|
376
|
-
```
|
|
377
|
-
|
|
378
|
-
### Advanced Examples
|
|
379
|
-
|
|
380
|
-
#### Rate Limit by User
|
|
381
|
-
|
|
382
|
-
```typescript
|
|
383
|
-
const userPolicy: Policy = {
|
|
384
|
-
name: 'per_user',
|
|
385
|
-
limit: 100,
|
|
386
|
-
window: 3600, // 1 hour
|
|
387
|
-
keyStrategy: KeyStrategy.USER,
|
|
388
|
-
};
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
#### Rate Limit by API Key
|
|
392
|
-
|
|
393
|
-
```typescript
|
|
394
|
-
const apiPolicy: Policy = {
|
|
395
|
-
name: 'per_api_key',
|
|
396
|
-
limit: 1000,
|
|
397
|
-
window: 60,
|
|
398
|
-
keyStrategy: KeyStrategy.API_KEY,
|
|
399
|
-
};
|
|
400
|
-
```
|
|
401
|
-
|
|
402
|
-
#### Composite Keys (User + IP)
|
|
403
|
-
|
|
404
|
-
```typescript
|
|
405
|
-
const compositePolicy: Policy = {
|
|
406
|
-
name: 'user_and_ip',
|
|
407
|
-
limit: 50,
|
|
408
|
-
window: 60,
|
|
409
|
-
keyStrategy: KeyStrategy.COMPOSITE,
|
|
410
|
-
};
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
#### Weighted Endpoints
|
|
414
|
-
|
|
415
|
-
```typescript
|
|
416
|
-
const expensivePolicy: Policy = {
|
|
417
|
-
name: 'llm_endpoint',
|
|
418
|
-
limit: 100,
|
|
419
|
-
window: 3600,
|
|
420
|
-
cost: 10, // Each request costs 10 tokens
|
|
421
|
-
algorithm: Algorithm.TOKEN_BUCKET,
|
|
422
|
-
};
|
|
13
|
+
npm install halt-rate
|
|
14
|
+
# optional: production store + metrics
|
|
15
|
+
npm install ioredis @opentelemetry/api
|
|
423
16
|
```
|
|
424
17
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
## Algorithms
|
|
428
|
-
|
|
429
|
-
### Token Bucket (Recommended)
|
|
430
|
-
|
|
431
|
-
Best for most use cases. Handles bursts naturally while maintaining average rate.
|
|
432
|
-
|
|
433
|
-
```typescript
|
|
434
|
-
import { Policy, Algorithm } from 'halt';
|
|
435
|
-
|
|
436
|
-
const policy: Policy = {
|
|
437
|
-
name: 'token_bucket',
|
|
438
|
-
limit: 100, // 100 tokens per window
|
|
439
|
-
window: 60, // 1 minute
|
|
440
|
-
burst: 120, // Allow bursts up to 120
|
|
441
|
-
algorithm: Algorithm.TOKEN_BUCKET,
|
|
442
|
-
};
|
|
443
|
-
```
|
|
444
|
-
|
|
445
|
-
**Pros:**
|
|
446
|
-
- ✅ Handles burst traffic naturally
|
|
447
|
-
- ✅ Smooth rate limiting
|
|
448
|
-
- ✅ Low memory usage
|
|
449
|
-
|
|
450
|
-
**Cons:**
|
|
451
|
-
- ❌ Slightly more complex than fixed window
|
|
452
|
-
|
|
453
|
-
### Fixed Window
|
|
454
|
-
|
|
455
|
-
Simple and fast. Good for strict limits.
|
|
18
|
+
## Quick start
|
|
456
19
|
|
|
457
20
|
```typescript
|
|
458
|
-
|
|
459
|
-
name: 'fixed_window',
|
|
460
|
-
limit: 100,
|
|
461
|
-
window: 60,
|
|
462
|
-
algorithm: Algorithm.FIXED_WINDOW,
|
|
463
|
-
};
|
|
464
|
-
```
|
|
465
|
-
|
|
466
|
-
**Pros:**
|
|
467
|
-
- ✅ Very simple
|
|
468
|
-
- ✅ Low memory usage
|
|
469
|
-
- ✅ Fast
|
|
470
|
-
|
|
471
|
-
**Cons:**
|
|
472
|
-
- ❌ Can allow 2x limit at window boundaries
|
|
473
|
-
- ❌ No burst handling
|
|
474
|
-
|
|
475
|
-
### Sliding Window
|
|
476
|
-
|
|
477
|
-
Most accurate but uses more memory.
|
|
478
|
-
|
|
479
|
-
```typescript
|
|
480
|
-
const policy: Policy = {
|
|
481
|
-
name: 'sliding_window',
|
|
482
|
-
limit: 100,
|
|
483
|
-
window: 60,
|
|
484
|
-
algorithm: Algorithm.SLIDING_WINDOW,
|
|
485
|
-
};
|
|
486
|
-
```
|
|
487
|
-
|
|
488
|
-
**Pros:**
|
|
489
|
-
- ✅ Most accurate
|
|
490
|
-
- ✅ No boundary issues
|
|
491
|
-
|
|
492
|
-
**Cons:**
|
|
493
|
-
- ❌ Higher memory usage
|
|
494
|
-
- ❌ Slightly slower
|
|
21
|
+
import { RateLimiter, InMemoryStore, presets } from 'halt-rate';
|
|
495
22
|
|
|
496
|
-
### Leaky Bucket
|
|
497
|
-
|
|
498
|
-
Traffic shaping with constant processing rate.
|
|
499
|
-
|
|
500
|
-
```typescript
|
|
501
|
-
const policy: Policy = {
|
|
502
|
-
name: 'leaky_bucket',
|
|
503
|
-
limit: 100,
|
|
504
|
-
window: 60,
|
|
505
|
-
burst: 120,
|
|
506
|
-
algorithm: Algorithm.LEAKY_BUCKET,
|
|
507
|
-
};
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
**Pros:**
|
|
511
|
-
- ✅ Smooth traffic shaping
|
|
512
|
-
- ✅ Predictable behavior
|
|
513
|
-
|
|
514
|
-
**Cons:**
|
|
515
|
-
- ❌ May delay legitimate bursts
|
|
516
|
-
|
|
517
|
-
**Use case:** Strict QoS requirements, traffic shaping
|
|
518
|
-
|
|
519
|
-
---
|
|
520
|
-
|
|
521
|
-
## Key Strategies
|
|
522
|
-
|
|
523
|
-
### IP-based (Default)
|
|
524
|
-
|
|
525
|
-
```typescript
|
|
526
|
-
import { Policy, KeyStrategy, RateLimiter } from 'halt';
|
|
527
|
-
|
|
528
|
-
const policy: Policy = {
|
|
529
|
-
name: 'per_ip',
|
|
530
|
-
limit: 100,
|
|
531
|
-
window: 60,
|
|
532
|
-
keyStrategy: KeyStrategy.IP,
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
// With trusted proxies (for X-Forwarded-For)
|
|
536
23
|
const limiter = new RateLimiter({
|
|
537
|
-
store,
|
|
538
|
-
policy,
|
|
539
|
-
trustedProxies: ['10.0.0.0/8', '172.16.0.0/12'],
|
|
24
|
+
store: new InMemoryStore(), // use RedisStore in production
|
|
25
|
+
policy: presets.PUBLIC_API, // 100 req/min per IP
|
|
540
26
|
});
|
|
541
|
-
```
|
|
542
|
-
|
|
543
|
-
### User-based
|
|
544
|
-
|
|
545
|
-
```typescript
|
|
546
|
-
const policy: Policy = {
|
|
547
|
-
name: 'per_user',
|
|
548
|
-
limit: 1000,
|
|
549
|
-
window: 3600,
|
|
550
|
-
keyStrategy: KeyStrategy.USER,
|
|
551
|
-
};
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
Extracts user ID from:
|
|
555
|
-
- `request.user.id`
|
|
556
|
-
- `request.userId`
|
|
557
27
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const policy: Policy = {
|
|
562
|
-
name: 'per_api_key',
|
|
563
|
-
limit: 5000,
|
|
564
|
-
window: 3600,
|
|
565
|
-
keyStrategy: KeyStrategy.API_KEY,
|
|
566
|
-
};
|
|
567
|
-
```
|
|
568
|
-
|
|
569
|
-
Extracts API key from headers:
|
|
570
|
-
- `X-API-Key`
|
|
571
|
-
- `Authorization` (including Bearer tokens)
|
|
572
|
-
|
|
573
|
-
### Custom Key Extraction
|
|
574
|
-
|
|
575
|
-
```typescript
|
|
576
|
-
function extractOrgId(request: any): string | null {
|
|
577
|
-
return request.headers['x-organization-id'] || null;
|
|
28
|
+
const decision = await limiter.check(req);
|
|
29
|
+
if (!decision.allowed) {
|
|
30
|
+
// respond 429, Retry-After: decision.retryAfter
|
|
578
31
|
}
|
|
579
|
-
|
|
580
|
-
const policy: Policy = {
|
|
581
|
-
name: 'per_org',
|
|
582
|
-
limit: 10000,
|
|
583
|
-
window: 3600,
|
|
584
|
-
keyStrategy: KeyStrategy.CUSTOM,
|
|
585
|
-
keyExtractor: extractOrgId,
|
|
586
|
-
};
|
|
587
|
-
```
|
|
588
|
-
|
|
589
|
-
---
|
|
590
|
-
|
|
591
|
-
## Exemptions
|
|
592
|
-
|
|
593
|
-
### Automatic Exemptions
|
|
594
|
-
|
|
595
|
-
Halt automatically exempts:
|
|
596
|
-
|
|
597
|
-
**Health Checks:**
|
|
598
|
-
- `/health`
|
|
599
|
-
- `/ping`
|
|
600
|
-
- `/ready`
|
|
601
|
-
- `/healthz`
|
|
602
|
-
- `/livez`
|
|
603
|
-
|
|
604
|
-
**Private IPs:**
|
|
605
|
-
- `127.0.0.1` (localhost)
|
|
606
|
-
- `10.0.0.0/8`
|
|
607
|
-
- `172.16.0.0/12`
|
|
608
|
-
- `192.168.0.0/16`
|
|
609
|
-
|
|
610
|
-
### Custom Exemptions
|
|
611
|
-
|
|
612
|
-
```typescript
|
|
613
|
-
const policy: Policy = {
|
|
614
|
-
name: 'custom',
|
|
615
|
-
limit: 100,
|
|
616
|
-
window: 60,
|
|
617
|
-
exemptions: [
|
|
618
|
-
'/admin', // Path exemption
|
|
619
|
-
'/internal', // Another path
|
|
620
|
-
'192.168.1.100', // IP exemption
|
|
621
|
-
],
|
|
622
|
-
};
|
|
623
|
-
|
|
624
|
-
// Disable private IP exemptions
|
|
625
|
-
const limiter = new RateLimiter({
|
|
626
|
-
store,
|
|
627
|
-
policy,
|
|
628
|
-
exemptPrivateIps: false,
|
|
629
|
-
});
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
---
|
|
633
|
-
|
|
634
|
-
## Per-Route Rate Limiting
|
|
635
|
-
|
|
636
|
-
### Express - Route-Specific
|
|
637
|
-
|
|
638
|
-
```typescript
|
|
639
|
-
import { createLimiter } from 'halt/express';
|
|
640
|
-
|
|
641
|
-
const publicLimiter = new RateLimiter({ store, policy: presets.PUBLIC_API });
|
|
642
|
-
const authLimiter = new RateLimiter({ store, policy: presets.AUTH_ENDPOINTS });
|
|
643
|
-
|
|
644
|
-
app.get('/api/data', createLimiter(publicLimiter), (req, res) => {
|
|
645
|
-
res.json({ data: '...' });
|
|
646
|
-
});
|
|
647
|
-
|
|
648
|
-
app.post('/auth/login', createLimiter(authLimiter), (req, res) => {
|
|
649
|
-
res.json({ token: '...' });
|
|
650
|
-
});
|
|
651
32
|
```
|
|
652
33
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
```typescript
|
|
656
|
-
// app/api/data/route.ts
|
|
657
|
-
import { withPolicy } from 'halt/next';
|
|
658
|
-
import { InMemoryStore, presets } from 'halt';
|
|
659
|
-
|
|
660
|
-
const store = new InMemoryStore();
|
|
34
|
+
Production Redis, Express/Next adapters, plan-based limits, quotas, penalties, and
|
|
35
|
+
observability are covered in the docs → **[halt.afroawi.com/docs](https://halt.afroawi.com/docs)**.
|
|
661
36
|
|
|
662
|
-
|
|
663
|
-
return Response.json({ data: '...' });
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
export const GET = withPolicy(handler, presets.PUBLIC_API, store);
|
|
667
|
-
```
|
|
668
|
-
|
|
669
|
-
```typescript
|
|
670
|
-
// app/api/auth/login/route.ts
|
|
671
|
-
import { withPolicy } from 'halt/next';
|
|
672
|
-
import { InMemoryStore, presets } from 'halt';
|
|
673
|
-
|
|
674
|
-
const store = new InMemoryStore();
|
|
675
|
-
|
|
676
|
-
async function handler(req: Request) {
|
|
677
|
-
return Response.json({ token: '...' });
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
export const POST = withPolicy(handler, presets.AUTH_ENDPOINTS, store);
|
|
681
|
-
```
|
|
682
|
-
|
|
683
|
-
---
|
|
684
|
-
|
|
685
|
-
## Response Headers
|
|
686
|
-
|
|
687
|
-
All responses include standard rate limit headers:
|
|
688
|
-
|
|
689
|
-
```http
|
|
690
|
-
HTTP/1.1 200 OK
|
|
691
|
-
RateLimit-Limit: 100
|
|
692
|
-
RateLimit-Remaining: 95
|
|
693
|
-
RateLimit-Reset: 1708024800
|
|
694
|
-
```
|
|
695
|
-
|
|
696
|
-
When rate limited (429):
|
|
697
|
-
|
|
698
|
-
```http
|
|
699
|
-
HTTP/1.1 429 Too Many Requests
|
|
700
|
-
RateLimit-Limit: 100
|
|
701
|
-
RateLimit-Remaining: 0
|
|
702
|
-
RateLimit-Reset: 1708024860
|
|
703
|
-
Retry-After: 42
|
|
704
|
-
|
|
705
|
-
{
|
|
706
|
-
"error": "rate_limit_exceeded",
|
|
707
|
-
"message": "Too many requests. Please try again later.",
|
|
708
|
-
"retryAfter": 42
|
|
709
|
-
}
|
|
710
|
-
```
|
|
711
|
-
|
|
712
|
-
---
|
|
713
|
-
|
|
714
|
-
## Advanced Usage
|
|
715
|
-
|
|
716
|
-
### Dynamic Cost per Request
|
|
717
|
-
|
|
718
|
-
```typescript
|
|
719
|
-
// Next.js API Route
|
|
720
|
-
import { RateLimiter, InMemoryStore, presets } from 'halt';
|
|
721
|
-
|
|
722
|
-
const limiter = new RateLimiter({
|
|
723
|
-
store: new InMemoryStore(),
|
|
724
|
-
policy: presets.EXPENSIVE_OPS,
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
export async function POST(req: Request) {
|
|
728
|
-
const body = await req.json();
|
|
729
|
-
const promptLength = body.prompt?.length || 0;
|
|
730
|
-
|
|
731
|
-
// Calculate cost based on request
|
|
732
|
-
const cost = Math.max(1, Math.floor(promptLength / 100));
|
|
733
|
-
|
|
734
|
-
// Check with custom cost
|
|
735
|
-
const decision = limiter.check(req, cost);
|
|
736
|
-
|
|
737
|
-
if (!decision.allowed) {
|
|
738
|
-
return Response.json(
|
|
739
|
-
{
|
|
740
|
-
error: 'rate_limit_exceeded',
|
|
741
|
-
message: 'Too many requests',
|
|
742
|
-
retryAfter: decision.retryAfter,
|
|
743
|
-
},
|
|
744
|
-
{ status: 429 }
|
|
745
|
-
);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
return Response.json({ response: '...' });
|
|
749
|
-
}
|
|
750
|
-
```
|
|
751
|
-
|
|
752
|
-
### Multiple Policies (Express)
|
|
753
|
-
|
|
754
|
-
```typescript
|
|
755
|
-
import express from 'express';
|
|
756
|
-
import { RateLimiter, InMemoryStore, presets } from 'halt';
|
|
757
|
-
import { haltMiddleware, createLimiter } from 'halt/express';
|
|
758
|
-
|
|
759
|
-
const app = express();
|
|
760
|
-
|
|
761
|
-
// Global rate limit
|
|
762
|
-
const globalLimiter = new RateLimiter({
|
|
763
|
-
store: new InMemoryStore(),
|
|
764
|
-
policy: presets.GENEROUS_API,
|
|
765
|
-
});
|
|
766
|
-
app.use(haltMiddleware({ limiter: globalLimiter }));
|
|
767
|
-
|
|
768
|
-
// Endpoint-specific limits
|
|
769
|
-
const authLimiter = new RateLimiter({
|
|
770
|
-
store: new InMemoryStore(),
|
|
771
|
-
policy: presets.AUTH_ENDPOINTS,
|
|
772
|
-
});
|
|
773
|
-
|
|
774
|
-
app.post('/auth/login', createLimiter(authLimiter), (req, res) => {
|
|
775
|
-
// This endpoint has BOTH global AND auth limits
|
|
776
|
-
res.json({ token: '...' });
|
|
777
|
-
});
|
|
778
|
-
```
|
|
779
|
-
|
|
780
|
-
### Custom Blocked Response
|
|
781
|
-
|
|
782
|
-
```typescript
|
|
783
|
-
import { haltMiddleware } from 'halt/express';
|
|
784
|
-
|
|
785
|
-
app.use(haltMiddleware({
|
|
786
|
-
limiter,
|
|
787
|
-
onBlocked: (req, res) => {
|
|
788
|
-
res.status(429).json({
|
|
789
|
-
error: 'RATE_LIMIT_EXCEEDED',
|
|
790
|
-
message: 'Slow down! Try again later.',
|
|
791
|
-
timestamp: Date.now(),
|
|
792
|
-
});
|
|
793
|
-
},
|
|
794
|
-
}));
|
|
795
|
-
```
|
|
796
|
-
|
|
797
|
-
---
|
|
798
|
-
|
|
799
|
-
## Testing
|
|
800
|
-
|
|
801
|
-
```typescript
|
|
802
|
-
import { describe, it, expect } from 'vitest';
|
|
803
|
-
import { RateLimiter, InMemoryStore, Policy, Algorithm } from 'halt';
|
|
804
|
-
|
|
805
|
-
describe('Rate Limiting', () => {
|
|
806
|
-
it('should block after limit exceeded', () => {
|
|
807
|
-
const policy: Policy = {
|
|
808
|
-
name: 'test',
|
|
809
|
-
limit: 5,
|
|
810
|
-
window: 60,
|
|
811
|
-
algorithm: Algorithm.TOKEN_BUCKET,
|
|
812
|
-
};
|
|
813
|
-
|
|
814
|
-
const limiter = new RateLimiter({
|
|
815
|
-
store: new InMemoryStore(),
|
|
816
|
-
policy,
|
|
817
|
-
});
|
|
818
|
-
|
|
819
|
-
// Mock request
|
|
820
|
-
const request = {
|
|
821
|
-
socket: { remoteAddress: '127.0.0.1' },
|
|
822
|
-
headers: {},
|
|
823
|
-
};
|
|
824
|
-
|
|
825
|
-
// First 5 requests should succeed
|
|
826
|
-
for (let i = 0; i < 5; i++) {
|
|
827
|
-
const decision = limiter.check(request);
|
|
828
|
-
expect(decision.allowed).toBe(true);
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
// 6th request should be blocked
|
|
832
|
-
const decision = limiter.check(request);
|
|
833
|
-
expect(decision.allowed).toBe(false);
|
|
834
|
-
expect(decision.retryAfter).toBeGreaterThan(0);
|
|
835
|
-
});
|
|
836
|
-
});
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
---
|
|
840
|
-
|
|
841
|
-
## Troubleshooting
|
|
842
|
-
|
|
843
|
-
### Rate limits not working?
|
|
844
|
-
|
|
845
|
-
1. **Check if request is exempted:**
|
|
846
|
-
- Health check paths are auto-exempted
|
|
847
|
-
- Private IPs are auto-exempted (disable with `exemptPrivateIps: false`)
|
|
848
|
-
|
|
849
|
-
2. **Verify key extraction:**
|
|
850
|
-
```typescript
|
|
851
|
-
// Debug key extraction
|
|
852
|
-
const key = (limiter as any).extractKey(request);
|
|
853
|
-
console.log('Rate limit key:', key);
|
|
854
|
-
```
|
|
855
|
-
|
|
856
|
-
3. **Check storage:**
|
|
857
|
-
- InMemoryStore doesn't persist across restarts
|
|
858
|
-
- Each process has its own memory store
|
|
859
|
-
|
|
860
|
-
### Headers not appearing?
|
|
861
|
-
|
|
862
|
-
Make sure middleware is added correctly and responses are going through the middleware chain.
|
|
863
|
-
|
|
864
|
-
### Different limits for same IP?
|
|
865
|
-
|
|
866
|
-
You might be using different policy names. Each policy maintains separate counters:
|
|
867
|
-
|
|
868
|
-
```typescript
|
|
869
|
-
// These are SEPARATE limits
|
|
870
|
-
const policy1: Policy = { name: 'api_v1', limit: 100, window: 60 };
|
|
871
|
-
const policy2: Policy = { name: 'api_v2', limit: 100, window: 60 };
|
|
872
|
-
```
|
|
873
|
-
|
|
874
|
-
---
|
|
875
|
-
|
|
876
|
-
## Performance
|
|
877
|
-
|
|
878
|
-
| Algorithm | Throughput | Memory | Accuracy |
|
|
879
|
-
|-----------|-----------|--------|----------|
|
|
880
|
-
| Token Bucket | ~100k req/s | Low | High |
|
|
881
|
-
| Fixed Window | ~120k req/s | Very Low | Medium |
|
|
882
|
-
| Sliding Window | ~80k req/s | Medium | Very High |
|
|
883
|
-
| Leaky Bucket | ~90k req/s | Low | High |
|
|
884
|
-
|
|
885
|
-
*Benchmarks on M1 Mac, in-memory storage*
|
|
886
|
-
|
|
887
|
-
All algorithms use O(1) memory per key (except Sliding Window which uses O(precision) per key).
|
|
888
|
-
|
|
889
|
-
---
|
|
890
|
-
|
|
891
|
-
## TypeScript Support
|
|
37
|
+
## Features
|
|
892
38
|
|
|
893
|
-
|
|
39
|
+
- Algorithms: token bucket, fixed/sliding window, leaky bucket
|
|
40
|
+
- Keys: IP, user, API key, composite, or custom
|
|
41
|
+
- Atomic **Redis** store (Lua, cluster-safe, fail-open) + in-memory dev store
|
|
42
|
+
- **Dynamic limits**: change limits at runtime with `PolicyRegistry` / cached loaders — no restart
|
|
43
|
+
- SaaS: per-plan limits, quotas, weighted endpoints, abuse penalties
|
|
44
|
+
- Observability: `StatsCollector` + OpenTelemetry metrics
|
|
45
|
+
- Adapters: Express, Next.js, **Hono**, **Fastify**, GraphQL (Apollo)
|
|
894
46
|
|
|
895
|
-
|
|
896
|
-
import type { Policy, Decision, RateLimiterOptions } from 'halt';
|
|
47
|
+
## Runtime support
|
|
897
48
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
};
|
|
49
|
+
Ships dual **ESM + CJS** with types. The core (limiter, algorithms, in-memory store) is
|
|
50
|
+
**edge-safe** — runs on Cloudflare Workers, Deno, Bun, and Vercel Edge. `RedisStore` is Node-only
|
|
51
|
+
(TCP); for edge distributed limits, inject any `RedisClientLike` (e.g. a fetch/REST client).
|
|
52
|
+
Full guidance: **[halt.afroawi.com/docs](https://halt.afroawi.com/docs)**.
|
|
903
53
|
|
|
904
|
-
|
|
905
|
-
```
|
|
54
|
+
## Links
|
|
906
55
|
|
|
907
|
-
|
|
56
|
+
- Docs: https://halt.afroawi.com
|
|
57
|
+
- npm: https://www.npmjs.com/package/halt-rate
|
|
58
|
+
- Source & issues: https://github.com/surafel-kindu/halt
|
|
908
59
|
|
|
909
60
|
## License
|
|
910
61
|
|
|
911
62
|
MIT
|
|
912
|
-
|
|
913
|
-
---
|
|
914
|
-
|
|
915
|
-
## Contributing
|
|
916
|
-
|
|
917
|
-
Contributions welcome! Please open an issue or PR on GitHub.
|
|
918
|
-
|
|
919
|
-
---
|
|
920
|
-
|
|
921
|
-
## Roadmap
|
|
922
|
-
|
|
923
|
-
### v0.3 (Current)
|
|
924
|
-
- ✅ Token Bucket algorithm
|
|
925
|
-
- ✅ Fixed Window algorithm
|
|
926
|
-
- ✅ Sliding Window algorithm
|
|
927
|
-
- ✅ Leaky Bucket algorithm
|
|
928
|
-
- ✅ In-memory storage
|
|
929
|
-
- ✅ PostgreSQL storage
|
|
930
|
-
- ✅ MongoDB storage
|
|
931
|
-
- ✅ DynamoDB storage
|
|
932
|
-
- ✅ Memcached storage
|
|
933
|
-
- ✅ Quota system
|
|
934
|
-
- ✅ Penalty system
|
|
935
|
-
- ✅ Telemetry hooks
|
|
936
|
-
- ✅ Plan-based presets
|
|
937
|
-
- ⏳ Redis storage
|
|
938
|
-
|
|
939
|
-
### v0.4 (Next)
|
|
940
|
-
- OpenTelemetry integration
|
|
941
|
-
- Distributed global limits
|
|
942
|
-
- Idempotent response mode
|
|
943
|
-
- Enhanced metrics and dashboards
|
|
944
|
-
|
|
945
|
-
### v1.0 (Future)
|
|
946
|
-
- Adaptive limits
|
|
947
|
-
- Advanced abuse detection
|
|
948
|
-
- Multi-region support
|
|
949
|
-
- GraphQL support
|