@donkeylabs/server 0.1.0 → 0.1.2
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/cli/commands/init.ts +201 -12
- package/cli/donkeylabs +6 -0
- package/context.d.ts +17 -0
- package/docs/api-client.md +520 -0
- package/docs/cache.md +437 -0
- package/docs/cli.md +353 -0
- package/docs/core-services.md +338 -0
- package/docs/cron.md +465 -0
- package/docs/errors.md +303 -0
- package/docs/events.md +460 -0
- package/docs/handlers.md +549 -0
- package/docs/jobs.md +556 -0
- package/docs/logger.md +316 -0
- package/docs/middleware.md +682 -0
- package/docs/plugins.md +524 -0
- package/docs/project-structure.md +493 -0
- package/docs/rate-limiter.md +525 -0
- package/docs/router.md +566 -0
- package/docs/sse.md +542 -0
- package/docs/svelte-frontend.md +324 -0
- package/package.json +12 -9
- package/registry.d.ts +11 -0
- package/src/index.ts +1 -1
- package/src/server.ts +1 -0
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# Rate Limiter Service
|
|
2
|
+
|
|
3
|
+
Request throttling with sliding window algorithm and automatic IP detection from proxy headers.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// Check rate limit
|
|
9
|
+
const result = await ctx.core.rateLimiter.check(`api:${ctx.ip}`, 100, 60000);
|
|
10
|
+
|
|
11
|
+
if (!result.allowed) {
|
|
12
|
+
return new Response("Too Many Requests", {
|
|
13
|
+
status: 429,
|
|
14
|
+
headers: { "Retry-After": String(result.retryAfter) },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## API Reference
|
|
22
|
+
|
|
23
|
+
### Interface
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
interface RateLimiter {
|
|
27
|
+
check(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
28
|
+
reset(key: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RateLimitResult {
|
|
32
|
+
allowed: boolean; // Whether request is allowed
|
|
33
|
+
remaining: number; // Requests remaining in window
|
|
34
|
+
limit: number; // Total limit
|
|
35
|
+
resetAt: Date; // When window resets
|
|
36
|
+
retryAfter?: number; // Seconds until retry (if blocked)
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Methods
|
|
41
|
+
|
|
42
|
+
| Method | Description |
|
|
43
|
+
|--------|-------------|
|
|
44
|
+
| `check(key, limit, windowMs)` | Check and increment counter for key |
|
|
45
|
+
| `reset(key)` | Reset counter for key |
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## IP Extraction
|
|
50
|
+
|
|
51
|
+
The framework automatically extracts client IP and provides it as `ctx.ip`. Headers are checked in priority order:
|
|
52
|
+
|
|
53
|
+
1. `CF-Connecting-IP` (Cloudflare)
|
|
54
|
+
2. `True-Client-IP` (Akamai, Cloudflare Enterprise)
|
|
55
|
+
3. `X-Real-IP` (Nginx)
|
|
56
|
+
4. `X-Forwarded-For` (first IP in chain)
|
|
57
|
+
5. Socket address (direct connection)
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
router.route("protected").typed({
|
|
61
|
+
handle: async (input, ctx) => {
|
|
62
|
+
console.log("Client IP:", ctx.ip); // Automatically extracted
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Usage Examples
|
|
70
|
+
|
|
71
|
+
### Basic Rate Limiting
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
router.route("api").typed({
|
|
75
|
+
handle: async (input, ctx) => {
|
|
76
|
+
// 100 requests per minute per IP
|
|
77
|
+
const result = await ctx.core.rateLimiter.check(
|
|
78
|
+
`api:${ctx.ip}`,
|
|
79
|
+
100,
|
|
80
|
+
60000
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
if (!result.allowed) {
|
|
84
|
+
return Response.json(
|
|
85
|
+
{ error: "Rate limit exceeded", retryAfter: result.retryAfter },
|
|
86
|
+
{
|
|
87
|
+
status: 429,
|
|
88
|
+
headers: {
|
|
89
|
+
"Retry-After": String(result.retryAfter),
|
|
90
|
+
"X-RateLimit-Limit": String(result.limit),
|
|
91
|
+
"X-RateLimit-Remaining": "0",
|
|
92
|
+
"X-RateLimit-Reset": result.resetAt.toISOString(),
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Process request...
|
|
99
|
+
return { data: "success" };
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Per-User Rate Limiting
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
router.route("user-action").typed({
|
|
108
|
+
handle: async (input, ctx) => {
|
|
109
|
+
// Rate limit by user, not IP (for authenticated routes)
|
|
110
|
+
const key = `user:${ctx.user.id}:action`;
|
|
111
|
+
const result = await ctx.core.rateLimiter.check(key, 10, 60000);
|
|
112
|
+
|
|
113
|
+
if (!result.allowed) {
|
|
114
|
+
throw new Error(`Rate limited. Try again in ${result.retryAfter}s`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return performAction(input);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Per-Route Rate Limiting
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// Different limits for different endpoints
|
|
126
|
+
const RATE_LIMITS: Record<string, { limit: number; windowMs: number }> = {
|
|
127
|
+
login: { limit: 5, windowMs: 60000 }, // 5/min
|
|
128
|
+
search: { limit: 30, windowMs: 60000 }, // 30/min
|
|
129
|
+
upload: { limit: 10, windowMs: 3600000 }, // 10/hour
|
|
130
|
+
default: { limit: 100, windowMs: 60000 }, // 100/min
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
async function checkRouteLimit(route: string, ip: string, ctx: ServerContext) {
|
|
134
|
+
const config = RATE_LIMITS[route] || RATE_LIMITS.default;
|
|
135
|
+
return ctx.core.rateLimiter.check(`${route}:${ip}`, config.limit, config.windowMs);
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Tiered Rate Limits
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// Different limits based on subscription tier
|
|
143
|
+
router.route("api").typed({
|
|
144
|
+
handle: async (input, ctx) => {
|
|
145
|
+
const tier = ctx.user?.tier || "free";
|
|
146
|
+
|
|
147
|
+
const limits: Record<string, { limit: number; window: number }> = {
|
|
148
|
+
free: { limit: 100, window: 3600000 }, // 100/hour
|
|
149
|
+
pro: { limit: 1000, window: 3600000 }, // 1000/hour
|
|
150
|
+
enterprise: { limit: 10000, window: 3600000 }, // 10000/hour
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const { limit, window } = limits[tier];
|
|
154
|
+
const key = `api:${ctx.user?.id || ctx.ip}:${tier}`;
|
|
155
|
+
|
|
156
|
+
const result = await ctx.core.rateLimiter.check(key, limit, window);
|
|
157
|
+
|
|
158
|
+
if (!result.allowed) {
|
|
159
|
+
return Response.json({
|
|
160
|
+
error: "Rate limit exceeded",
|
|
161
|
+
tier,
|
|
162
|
+
limit,
|
|
163
|
+
upgrade: tier === "free" ? "Upgrade to Pro for higher limits" : undefined,
|
|
164
|
+
}, { status: 429 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return processRequest(input);
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Rate Limit Middleware
|
|
175
|
+
|
|
176
|
+
Create reusable rate limit middleware:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
// middleware/rateLimit.ts
|
|
180
|
+
import { createMiddleware } from "../middleware";
|
|
181
|
+
import { parseDuration } from "../core/rate-limiter";
|
|
182
|
+
|
|
183
|
+
interface RateLimitConfig {
|
|
184
|
+
limit: number;
|
|
185
|
+
window: string; // "1m", "1h", etc.
|
|
186
|
+
keyPrefix?: string;
|
|
187
|
+
keyFn?: (ctx: ServerContext) => string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const rateLimitMiddleware = createMiddleware<RateLimitConfig>(
|
|
191
|
+
async (req, ctx, next, config) => {
|
|
192
|
+
const windowMs = parseDuration(config.window);
|
|
193
|
+
const keyBase = config.keyFn?.(ctx) ?? ctx.ip;
|
|
194
|
+
const key = config.keyPrefix
|
|
195
|
+
? `${config.keyPrefix}:${keyBase}`
|
|
196
|
+
: `ratelimit:${keyBase}`;
|
|
197
|
+
|
|
198
|
+
const result = await ctx.core.rateLimiter.check(key, config.limit, windowMs);
|
|
199
|
+
|
|
200
|
+
// Add rate limit headers to response
|
|
201
|
+
const response = result.allowed
|
|
202
|
+
? await next()
|
|
203
|
+
: Response.json({ error: "Too Many Requests" }, { status: 429 });
|
|
204
|
+
|
|
205
|
+
response.headers.set("X-RateLimit-Limit", String(result.limit));
|
|
206
|
+
response.headers.set("X-RateLimit-Remaining", String(result.remaining));
|
|
207
|
+
response.headers.set("X-RateLimit-Reset", result.resetAt.toISOString());
|
|
208
|
+
|
|
209
|
+
if (!result.allowed) {
|
|
210
|
+
response.headers.set("Retry-After", String(result.retryAfter));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return response;
|
|
214
|
+
}
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Register in plugin
|
|
218
|
+
export const rateLimitPlugin = createPlugin.define({
|
|
219
|
+
name: "rateLimit",
|
|
220
|
+
middleware: {
|
|
221
|
+
rateLimit: rateLimitMiddleware,
|
|
222
|
+
},
|
|
223
|
+
service: async () => ({}),
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Usage:**
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
router.middleware
|
|
231
|
+
.rateLimit({ limit: 100, window: "1m" })
|
|
232
|
+
.route("api")
|
|
233
|
+
.typed({ handle: ... });
|
|
234
|
+
|
|
235
|
+
router.middleware
|
|
236
|
+
.rateLimit({ limit: 5, window: "1h", keyPrefix: "login" })
|
|
237
|
+
.route("login")
|
|
238
|
+
.typed({ handle: ... });
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## Helper Functions
|
|
244
|
+
|
|
245
|
+
### parseDuration
|
|
246
|
+
|
|
247
|
+
Convert duration strings to milliseconds:
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
import { parseDuration } from "./core/rate-limiter";
|
|
251
|
+
|
|
252
|
+
parseDuration("100ms"); // 100
|
|
253
|
+
parseDuration("30s"); // 30000
|
|
254
|
+
parseDuration("5m"); // 300000
|
|
255
|
+
parseDuration("2h"); // 7200000
|
|
256
|
+
parseDuration("1d"); // 86400000
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### createRateLimitKey
|
|
260
|
+
|
|
261
|
+
Build consistent rate limit keys:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { createRateLimitKey } from "./core/rate-limiter";
|
|
265
|
+
|
|
266
|
+
const key = createRateLimitKey("api.users.list", "192.168.1.1");
|
|
267
|
+
// "ratelimit:api.users.list:192.168.1.1"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
### extractClientIP
|
|
271
|
+
|
|
272
|
+
Manual IP extraction if needed:
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { extractClientIP } from "./core/rate-limiter";
|
|
276
|
+
|
|
277
|
+
const ip = extractClientIP(req, socketAddr);
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Real-World Examples
|
|
283
|
+
|
|
284
|
+
### API Rate Limiting with Response Headers
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
async function withRateLimit(
|
|
288
|
+
ctx: ServerContext,
|
|
289
|
+
key: string,
|
|
290
|
+
limit: number,
|
|
291
|
+
windowMs: number,
|
|
292
|
+
handler: () => Promise<Response>
|
|
293
|
+
): Promise<Response> {
|
|
294
|
+
const result = await ctx.core.rateLimiter.check(key, limit, windowMs);
|
|
295
|
+
|
|
296
|
+
const headers = {
|
|
297
|
+
"X-RateLimit-Limit": String(result.limit),
|
|
298
|
+
"X-RateLimit-Remaining": String(result.remaining),
|
|
299
|
+
"X-RateLimit-Reset": String(Math.floor(result.resetAt.getTime() / 1000)),
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
if (!result.allowed) {
|
|
303
|
+
return Response.json(
|
|
304
|
+
{
|
|
305
|
+
error: "rate_limit_exceeded",
|
|
306
|
+
message: `Rate limit exceeded. Retry in ${result.retryAfter} seconds.`,
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
status: 429,
|
|
310
|
+
headers: {
|
|
311
|
+
...headers,
|
|
312
|
+
"Retry-After": String(result.retryAfter),
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const response = await handler();
|
|
319
|
+
|
|
320
|
+
// Add headers to successful response
|
|
321
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
322
|
+
response.headers.set(key, value);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return response;
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Login Brute Force Protection
|
|
330
|
+
|
|
331
|
+
```ts
|
|
332
|
+
router.route("login").typed({
|
|
333
|
+
handle: async (input, ctx) => {
|
|
334
|
+
// Strict limit on login attempts
|
|
335
|
+
const result = await ctx.core.rateLimiter.check(
|
|
336
|
+
`login:${ctx.ip}`,
|
|
337
|
+
5, // 5 attempts
|
|
338
|
+
300000 // per 5 minutes
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if (!result.allowed) {
|
|
342
|
+
ctx.core.logger.warn("Login rate limited", {
|
|
343
|
+
ip: ctx.ip,
|
|
344
|
+
email: input.email,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
return Response.json({
|
|
348
|
+
error: "Too many login attempts",
|
|
349
|
+
retryAfter: result.retryAfter,
|
|
350
|
+
}, { status: 429 });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const user = await authenticate(input.email, input.password);
|
|
354
|
+
|
|
355
|
+
if (!user) {
|
|
356
|
+
// Failed attempt still counts
|
|
357
|
+
return Response.json({ error: "Invalid credentials" }, { status: 401 });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Success - optionally reset limit
|
|
361
|
+
await ctx.core.rateLimiter.reset(`login:${ctx.ip}`);
|
|
362
|
+
|
|
363
|
+
return { token: generateToken(user) };
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
### Cost-Based Rate Limiting
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
// Different operations have different costs
|
|
372
|
+
const OPERATION_COSTS: Record<string, number> = {
|
|
373
|
+
"query.simple": 1,
|
|
374
|
+
"query.complex": 5,
|
|
375
|
+
"mutation.create": 2,
|
|
376
|
+
"mutation.bulkCreate": 10,
|
|
377
|
+
"export.csv": 20,
|
|
378
|
+
"export.pdf": 50,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
router.route("graphql").typed({
|
|
382
|
+
handle: async (input, ctx) => {
|
|
383
|
+
const operationType = analyzeQuery(input.query);
|
|
384
|
+
const cost = OPERATION_COSTS[operationType] || 1;
|
|
385
|
+
|
|
386
|
+
// 1000 cost units per hour
|
|
387
|
+
const key = `graphql:${ctx.user.id}`;
|
|
388
|
+
|
|
389
|
+
// Check if we have enough budget
|
|
390
|
+
const current = await ctx.core.cache.get<number>(`${key}:cost`) || 0;
|
|
391
|
+
|
|
392
|
+
if (current + cost > 1000) {
|
|
393
|
+
return Response.json({
|
|
394
|
+
error: "Rate limit exceeded",
|
|
395
|
+
cost,
|
|
396
|
+
used: current,
|
|
397
|
+
limit: 1000,
|
|
398
|
+
}, { status: 429 });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Increment cost
|
|
402
|
+
await ctx.core.cache.set(`${key}:cost`, current + cost, 3600000);
|
|
403
|
+
|
|
404
|
+
return executeQuery(input.query);
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
---
|
|
410
|
+
|
|
411
|
+
## Custom Adapters
|
|
412
|
+
|
|
413
|
+
Implement `RateLimitAdapter` for custom backends:
|
|
414
|
+
|
|
415
|
+
```ts
|
|
416
|
+
interface RateLimitAdapter {
|
|
417
|
+
increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }>;
|
|
418
|
+
get(key: string): Promise<{ count: number; resetAt: Date } | null>;
|
|
419
|
+
reset(key: string): Promise<void>;
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### Redis Adapter Example
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
import { createRateLimiter, type RateLimitAdapter } from "./core/rate-limiter";
|
|
427
|
+
import Redis from "ioredis";
|
|
428
|
+
|
|
429
|
+
class RedisRateLimitAdapter implements RateLimitAdapter {
|
|
430
|
+
constructor(private redis: Redis) {}
|
|
431
|
+
|
|
432
|
+
async increment(key: string, windowMs: number): Promise<{ count: number; resetAt: Date }> {
|
|
433
|
+
const now = Date.now();
|
|
434
|
+
const windowKey = `${key}:${Math.floor(now / windowMs)}`;
|
|
435
|
+
|
|
436
|
+
const count = await this.redis.incr(windowKey);
|
|
437
|
+
|
|
438
|
+
if (count === 1) {
|
|
439
|
+
// Set expiry on first request in window
|
|
440
|
+
await this.redis.pexpire(windowKey, windowMs);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const resetAt = new Date(Math.ceil(now / windowMs) * windowMs);
|
|
444
|
+
|
|
445
|
+
return { count, resetAt };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async get(key: string): Promise<{ count: number; resetAt: Date } | null> {
|
|
449
|
+
// Implementation for getting current state
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async reset(key: string): Promise<void> {
|
|
453
|
+
const keys = await this.redis.keys(`${key}:*`);
|
|
454
|
+
if (keys.length > 0) {
|
|
455
|
+
await this.redis.del(...keys);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const rateLimiter = createRateLimiter({
|
|
461
|
+
adapter: new RedisRateLimitAdapter(new Redis()),
|
|
462
|
+
});
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Best Practices
|
|
468
|
+
|
|
469
|
+
### 1. Use Appropriate Key Granularity
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
// Per IP for anonymous
|
|
473
|
+
`public:${ctx.ip}`
|
|
474
|
+
|
|
475
|
+
// Per user for authenticated
|
|
476
|
+
`user:${ctx.user.id}`
|
|
477
|
+
|
|
478
|
+
// Per user per endpoint
|
|
479
|
+
`user:${ctx.user.id}:${endpoint}`
|
|
480
|
+
|
|
481
|
+
// Per organization for team limits
|
|
482
|
+
`org:${ctx.user.orgId}`
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
### 2. Set Reasonable Limits
|
|
486
|
+
|
|
487
|
+
```ts
|
|
488
|
+
// Consider normal usage patterns
|
|
489
|
+
const limits = {
|
|
490
|
+
// Login: low limit, short window (brute force protection)
|
|
491
|
+
login: { limit: 5, window: "5m" },
|
|
492
|
+
|
|
493
|
+
// Search: moderate limit (expensive operations)
|
|
494
|
+
search: { limit: 30, window: "1m" },
|
|
495
|
+
|
|
496
|
+
// Read API: generous limit
|
|
497
|
+
read: { limit: 1000, window: "1h" },
|
|
498
|
+
|
|
499
|
+
// Write API: moderate limit
|
|
500
|
+
write: { limit: 100, window: "1h" },
|
|
501
|
+
};
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### 3. Return Helpful Headers
|
|
505
|
+
|
|
506
|
+
```ts
|
|
507
|
+
// Always include rate limit info in responses
|
|
508
|
+
response.headers.set("X-RateLimit-Limit", limit);
|
|
509
|
+
response.headers.set("X-RateLimit-Remaining", remaining);
|
|
510
|
+
response.headers.set("X-RateLimit-Reset", resetTimestamp);
|
|
511
|
+
response.headers.set("Retry-After", seconds); // Only when blocked
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
### 4. Log Rate Limit Events
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
if (!result.allowed) {
|
|
518
|
+
ctx.core.logger.warn("Rate limit exceeded", {
|
|
519
|
+
key,
|
|
520
|
+
ip: ctx.ip,
|
|
521
|
+
userId: ctx.user?.id,
|
|
522
|
+
endpoint: req.url,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
```
|