Skip to content

Commit d46b0db

Browse files
authored
Merge pull request #98 from jaredwray/feat-adding-in-rate-limit-options
feat: adding in rate limit options
2 parents ec0612b + cf635d2 commit d46b0db

4 files changed

Lines changed: 314 additions & 0 deletions

File tree

README.md

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,138 @@ const tap = mock.taps.inject(
247247
);
248248
```
249249

250+
# Rate Limiting
251+
252+
MockHttp supports rate limiting using [@fastify/rate-limit](https://github.com/fastify/fastify-rate-limit). Rate limiting is **disabled by default** and can be enabled by providing configuration options.
253+
254+
## Enabling Rate Limiting
255+
256+
To enable rate limiting, pass a `rateLimit` configuration object when creating your MockHttp instance:
257+
258+
```javascript
259+
import { MockHttp } from '@jaredwray/mockhttp';
260+
261+
const mock = new MockHttp({
262+
rateLimit: {
263+
max: 100, // Maximum 100 requests
264+
timeWindow: '1 minute' // Per 1 minute window
265+
}
266+
});
267+
268+
await mock.start();
269+
```
270+
271+
## Common Configuration Options
272+
273+
The `rateLimit` option accepts all [@fastify/rate-limit options](https://github.com/fastify/fastify-rate-limit#options):
274+
275+
### Basic Rate Limiting
276+
277+
```javascript
278+
// Limit to 50 requests per minute
279+
const mock = new MockHttp({
280+
rateLimit: {
281+
max: 50,
282+
timeWindow: '1 minute'
283+
}
284+
});
285+
```
286+
287+
### Stricter Limits with Custom Error Response
288+
289+
```javascript
290+
const mock = new MockHttp({
291+
rateLimit: {
292+
max: 30,
293+
timeWindow: 60000, // 1 minute in milliseconds
294+
errorResponseBuilder: (req, context) => ({
295+
statusCode: 429,
296+
error: 'Too Many Requests',
297+
message: `Rate limit exceeded. Try again in ${context.after}`
298+
})
299+
}
300+
});
301+
```
302+
303+
### Allow List (Exclude Specific IPs)
304+
305+
```javascript
306+
const mock = new MockHttp({
307+
rateLimit: {
308+
max: 100,
309+
timeWindow: '1 minute',
310+
allowList: ['127.0.0.1', '192.168.1.100'] // These IPs bypass rate limiting
311+
}
312+
});
313+
```
314+
315+
### Custom Key Generator (Rate Limit by Header)
316+
317+
```javascript
318+
const mock = new MockHttp({
319+
rateLimit: {
320+
max: 100,
321+
timeWindow: '1 minute',
322+
keyGenerator: (request) => {
323+
// Rate limit by API key instead of IP
324+
return request.headers['x-api-key'] || request.ip;
325+
}
326+
}
327+
});
328+
```
329+
330+
### Advanced Configuration
331+
332+
```javascript
333+
const mock = new MockHttp({
334+
rateLimit: {
335+
global: true, // Apply to all routes
336+
max: 100, // Max requests
337+
timeWindow: '1 minute', // Time window
338+
cache: 10000, // Cache size for tracking clients
339+
skipOnError: false, // Don't skip on storage errors
340+
ban: 10, // Ban after 10 rate limit violations
341+
continueExceeding: false, // Don't reset window on each request
342+
enableDraftSpec: true, // Use IETF draft spec headers
343+
addHeaders: { // Customize rate limit headers
344+
'x-ratelimit-limit': true,
345+
'x-ratelimit-remaining': true,
346+
'x-ratelimit-reset': true
347+
}
348+
}
349+
});
350+
```
351+
352+
## Disabling Rate Limiting
353+
354+
Rate limiting is disabled by default. To explicitly disable it (or disable it after it was enabled):
355+
356+
```javascript
357+
const mock = new MockHttp(); // No rateLimit option = disabled
358+
359+
// Or explicitly set to undefined
360+
const mock2 = new MockHttp({
361+
rateLimit: undefined
362+
});
363+
```
364+
365+
## Available Options
366+
367+
| Option | Type | Default | Description |
368+
|--------|------|---------|-------------|
369+
| `max` | number \| function | `1000` | Maximum requests per time window |
370+
| `timeWindow` | number \| string | `60000` | Duration of rate limit window (milliseconds or string like '1 minute') |
371+
| `cache` | number | `5000` | LRU cache size for tracking clients |
372+
| `allowList` | array \| function | `[]` | IPs or function to exclude from rate limiting |
373+
| `keyGenerator` | function | IP-based | Function to generate unique client identifier |
374+
| `errorResponseBuilder` | function | Default 429 | Custom error response function |
375+
| `skipOnError` | boolean | `false` | Skip rate limiting if storage errors occur |
376+
| `ban` | number | `-1` | Ban client after N violations (disabled by default) |
377+
| `continueExceeding` | boolean | `false` | Renew time window on each request while limited |
378+
| `enableDraftSpec` | boolean | `false` | Use IETF draft specification headers |
379+
380+
For the complete list of options, see the [@fastify/rate-limit documentation](https://github.com/fastify/fastify-rate-limit#options).
381+
250382
# API Reference
251383

252384
## MockHttp Class
@@ -264,6 +396,7 @@ new MockHttp(options?)
264396
- `autoDetectPort?`: boolean - Auto-detect next available port if in use (default: true)
265397
- `helmet?`: boolean - Use Helmet for security headers (default: true)
266398
- `apiDocs?`: boolean - Enable Swagger API documentation (default: true)
399+
- `rateLimit?`: RateLimitPluginOptions - Enable and configure rate limiting (default: undefined/disabled)
267400
- `httpBin?`: HttpBinOptions - Configure which httpbin routes to enable
268401
- `httpMethods?`: boolean - Enable HTTP method routes (default: true)
269402
- `redirects?`: boolean - Enable redirect routes (default: true)
@@ -284,6 +417,7 @@ new MockHttp(options?)
284417
- `autoDetectPort`: boolean - Get/set auto-detect port behavior
285418
- `helmet`: boolean - Get/set Helmet security headers
286419
- `apiDocs`: boolean - Get/set API documentation
420+
- `rateLimit`: RateLimitPluginOptions | undefined - Get/set rate limiting options
287421
- `httpBin`: HttpBinOptions - Get/set httpbin route options
288422
- `server`: FastifyInstance - Get/set the Fastify server instance
289423
- `taps`: TapManager - Get/set the TapManager instance

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"dependencies": {
5454
"@fastify/cookie": "^11.0.2",
5555
"@fastify/helmet": "^13.0.2",
56+
"@fastify/rate-limit": "^10.3.0",
5657
"@fastify/static": "^8.3.0",
5758
"@fastify/swagger": "^9.6.0",
5859
"@fastify/swagger-ui": "^5.2.3",

src/mock-http.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import path from "node:path";
22
import fastifyCookie from "@fastify/cookie";
33
import fastifyHelmet from "@fastify/helmet";
4+
import fastifyRateLimit, {
5+
type RateLimitPluginOptions,
6+
} from "@fastify/rate-limit";
47
import fastifyStatic from "@fastify/static";
58
import { fastifySwagger } from "@fastify/swagger";
69
import { detect } from "detect-port";
@@ -88,6 +91,11 @@ export type MockHttpOptions = {
8891
* Whether to use Swagger UI. Defaults to true.
8992
*/
9093
httpBin?: HttpBinOptions;
94+
/**
95+
* Rate limiting options. When undefined, rate limiting is disabled.
96+
* When set with options, rate limiting is enabled with those options.
97+
*/
98+
rateLimit?: RateLimitPluginOptions;
9199
/**
92100
* Hookified options.
93101
*/
@@ -113,6 +121,8 @@ export class MockHttp extends Hookified {
113121
images: true,
114122
};
115123

124+
private _rateLimit?: RateLimitPluginOptions;
125+
116126
private _server: FastifyInstance = Fastify();
117127
private _taps: TapManager = new TapManager();
118128

@@ -138,6 +148,10 @@ export class MockHttp extends Hookified {
138148
if (options?.httpBin !== undefined) {
139149
this._httpBin = options.httpBin;
140150
}
151+
152+
if (options?.rateLimit !== undefined) {
153+
this._rateLimit = options.rateLimit;
154+
}
141155
}
142156

143157
/**
@@ -236,6 +250,24 @@ export class MockHttp extends Hookified {
236250
this._httpBin = httpBinary;
237251
}
238252

253+
/**
254+
* Rate limiting options. When undefined, rate limiting is disabled.
255+
* When set with options, rate limiting is enabled with those options.
256+
* @default undefined
257+
*/
258+
public get rateLimit(): RateLimitPluginOptions | undefined {
259+
return this._rateLimit;
260+
}
261+
262+
/**
263+
* Rate limiting options. When undefined, rate limiting is disabled.
264+
* When set with options, rate limiting is enabled with those options.
265+
* @default undefined
266+
*/
267+
public set rateLimit(rateLimit: RateLimitPluginOptions | undefined) {
268+
this._rateLimit = rateLimit;
269+
}
270+
239271
/**
240272
* The Fastify server instance.
241273
*/
@@ -337,6 +369,11 @@ export class MockHttp extends Hookified {
337369
});
338370
}
339371

372+
// Register the rate limit plugin if configured
373+
if (this._rateLimit) {
374+
await this._server.register(fastifyRateLimit, this._rateLimit);
375+
}
376+
340377
if (this._apiDocs) {
341378
await this.registerApiDocs();
342379
}

test/mock-http.test.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,4 +433,146 @@ describe("MockHttp", () => {
433433
await mock.close();
434434
});
435435
});
436+
437+
describe("rate limiting", () => {
438+
test("should be disabled by default", async () => {
439+
const mock = new MockHttp();
440+
expect(mock.rateLimit).toBeUndefined();
441+
442+
await mock.start();
443+
444+
// Make multiple requests quickly - should all succeed
445+
const responses = await Promise.all([
446+
mock.server.inject({ method: "GET", url: "/get" }),
447+
mock.server.inject({ method: "GET", url: "/get" }),
448+
mock.server.inject({ method: "GET", url: "/get" }),
449+
mock.server.inject({ method: "GET", url: "/get" }),
450+
mock.server.inject({ method: "GET", url: "/get" }),
451+
]);
452+
453+
// All should return 200, not 429 (rate limit)
454+
for (const response of responses) {
455+
expect(response.statusCode).toBe(200);
456+
}
457+
458+
await mock.close();
459+
});
460+
461+
test("should be able to set rate limit options", () => {
462+
const rateLimitOptions = {
463+
max: 10,
464+
timeWindow: "1 minute",
465+
};
466+
467+
const mock = new MockHttp({
468+
rateLimit: rateLimitOptions,
469+
});
470+
471+
expect(mock.rateLimit).toEqual(rateLimitOptions);
472+
});
473+
474+
test("should be able to modify rate limit options via setter", () => {
475+
const mock = new MockHttp();
476+
expect(mock.rateLimit).toBeUndefined();
477+
478+
const rateLimitOptions = {
479+
max: 50,
480+
timeWindow: 60000,
481+
};
482+
483+
mock.rateLimit = rateLimitOptions;
484+
expect(mock.rateLimit).toEqual(rateLimitOptions);
485+
});
486+
487+
test("should enforce rate limits when enabled", async () => {
488+
const mock = new MockHttp({
489+
rateLimit: {
490+
max: 3, // Only allow 3 requests
491+
timeWindow: 60000, // Per minute
492+
},
493+
});
494+
495+
await mock.start();
496+
497+
// Make 3 requests - should all succeed
498+
const response1 = await mock.server.inject({
499+
method: "GET",
500+
url: "/get",
501+
});
502+
const response2 = await mock.server.inject({
503+
method: "GET",
504+
url: "/get",
505+
});
506+
const response3 = await mock.server.inject({
507+
method: "GET",
508+
url: "/get",
509+
});
510+
511+
expect(response1.statusCode).toBe(200);
512+
expect(response2.statusCode).toBe(200);
513+
expect(response3.statusCode).toBe(200);
514+
515+
// 4th request should be rate limited
516+
const response4 = await mock.server.inject({
517+
method: "GET",
518+
url: "/get",
519+
});
520+
expect(response4.statusCode).toBe(429); // Too Many Requests
521+
522+
await mock.close();
523+
});
524+
525+
test("should include rate limit headers when enabled", async () => {
526+
const mock = new MockHttp({
527+
rateLimit: {
528+
max: 100,
529+
timeWindow: "1 minute",
530+
},
531+
});
532+
533+
await mock.start();
534+
535+
const response = await mock.server.inject({ method: "GET", url: "/get" });
536+
537+
expect(response.statusCode).toBe(200);
538+
expect(response.headers["x-ratelimit-limit"]).toBeDefined();
539+
expect(response.headers["x-ratelimit-remaining"]).toBeDefined();
540+
expect(response.headers["x-ratelimit-reset"]).toBeDefined();
541+
542+
await mock.close();
543+
});
544+
545+
test("should support custom error response builder", async () => {
546+
const customErrorMessage = "Custom rate limit message";
547+
548+
const mock = new MockHttp({
549+
rateLimit: {
550+
max: 2,
551+
timeWindow: 60000,
552+
errorResponseBuilder: () => ({
553+
statusCode: 429,
554+
error: "Rate Limit Exceeded",
555+
message: customErrorMessage,
556+
}),
557+
},
558+
});
559+
560+
await mock.start();
561+
562+
// Make requests to exceed limit
563+
await mock.server.inject({ method: "GET", url: "/get" });
564+
await mock.server.inject({ method: "GET", url: "/get" });
565+
566+
const rateLimitedResponse = await mock.server.inject({
567+
method: "GET",
568+
url: "/get",
569+
});
570+
571+
expect(rateLimitedResponse.statusCode).toBe(429);
572+
const body = rateLimitedResponse.json();
573+
expect(body.message).toBe(customErrorMessage);
574+
575+
await mock.close();
576+
});
577+
});
436578
});

0 commit comments

Comments
 (0)