Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* [Example](#example)
* [With a Redis driver](#with-a-redis-driver)
* [With a memory driver](#with-a-memory-driver)
* [Group-Based Rate Limiting (New Feature)](#group-based-rate-limiting-new-feature)
* [Parameters](#parameters)
* [Options](#options)
* [Responses](#responses)
* [License](#license)
Expand Down Expand Up @@ -118,6 +120,47 @@ app.listen(
```


## Group-Based Rate Limiting (New Feature)

You can apply rate limits by group using the new `groupRateLimit` middleware:

```js
const Koa = require('koa');
const { groupRateLimit } = require('koa-ratelimit');

const app = new Koa();

app.use(
groupRateLimit({
routeMap: {
user: {
duration: 60000,
max: 10,
id: (ctx) => ctx.ip
},
admin: {
duration: 60000,
max: 100,
id: (ctx) => ctx.ip
}
},
groupBy: (ctx) => (ctx.path.startsWith('/admin') ? 'admin' : 'user')
})
);

app.use((ctx) => {
ctx.body = 'Hello, rate limited world!';
});
```

### Parameters

* `routeMap`: Object mapping group names to individual `ratelimit` configurations.
* `groupBy(ctx)`: Function that returns a group name for the current request.

This enables route-aware rate limiting logic for better control across app segments.


## Options

* `driver` memory or redis \[redis]
Expand Down
48 changes: 48 additions & 0 deletions group-limiter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
const ratelimit = require('./index');

/**
* Create group-based rate limiter middleware.
*
* @param {Object} options
* @param {Object} options.routeMap - Map of groupName -> rateLimitOptions
* @param {Function} options.groupBy - Function(ctx) => groupName
*/
module.exports = function (options) {
const { routeMap = {}, groupBy } = options;

if (typeof groupBy !== 'function') {
throw new TypeError('groupBy must be a function');
}

// Assign shared db per group if using memory driver
const sharedDbMap = {};

for (const [groupName, config] of Object.entries(routeMap)) {
if (config.driver === 'memory' || !config.driver) {
sharedDbMap[groupName] = config.db || new Map();
}
}

return async function (ctx, next) {
try {
const group = groupBy(ctx);
const baseOpts = routeMap[group];

if (!baseOpts) {
return await next(); // skip rate limiting if group not found
}

const groupOpts = {
driver: 'memory',
...baseOpts,
db: sharedDbMap[group] // reuse persistent in-memory store
};

const rateLimitMiddleware = ratelimit(groupOpts);
return await rateLimitMiddleware(ctx, next);
} catch (err) {
console.error('groupLimiter error:', err);
ctx.throw(500, 'Group limiter internal error');
}
};
};
2 changes: 2 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Module dependencies.
*/

const util = require('node:util');

Check warning on line 7 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

There should be no empty line between import groups

const debug = util.debuglog('koa-ratelimit');
const ms = require('ms');
Expand Down Expand Up @@ -35,7 +35,7 @@
* @api public
*/

module.exports = function ratelimit(opts = {}) {

Check warning on line 38 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Unexpected named function 'ratelimit'.
const defaultOpts = {
driver: 'redis',
duration: 60 * 60 * 1000, // 1 hour
Expand All @@ -57,7 +57,7 @@
total = 'X-RateLimit-Limit'
} = opts.headers;

return async function ratelimit(ctx, next) {

Check warning on line 60 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Unexpected named async function 'ratelimit'.
const id = opts.id(ctx);
const { driver } = opts;
const whitelisted =
Expand All @@ -69,7 +69,7 @@
ctx.throw(403, 'Forbidden');
}

if (id === false || whitelisted) return await next();

Check warning on line 72 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Redundant use of `await` on a return value.

// initialize limiter
let limiter;
Expand Down Expand Up @@ -105,10 +105,10 @@
}

debug('remaining %s/%s %s', remaining, limit.total, id);
if (limit.remaining) return await next();

Check warning on line 108 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Redundant use of `await` on a return value.

const delta = (limit.reset * 1000 - Date.now()) | 0;

Check warning on line 110 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Use `Math.trunc` instead of `| 0`.

Check warning on line 110 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Unexpected use of '|'.
const after = (limit.reset - Date.now() / 1000) | 0;

Check warning on line 111 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Use `Math.trunc` instead of `| 0`.

Check warning on line 111 in index.js

View workflow job for this annotation

GitHub Actions / Node 18 on ubuntu-latest

Unexpected use of '|'.
const message =
opts.errorMessage ||
`Rate limit exceeded, retry in ${ms(delta, { long: true })}.`;
Expand All @@ -126,3 +126,5 @@
}
};
};

module.exports.groupRateLimit = require('./group-limiter');
55 changes: 55 additions & 0 deletions test/group-limiter.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
const Koa = require('koa');
const request = require('supertest');
const groupRateLimit = require('../group-limiter');

// const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

describe('groupRateLimit middleware', () => {
let app;

beforeEach(() => {
app = new Koa();
});

it('applies rate limit per group', async () => {
const middleware = groupRateLimit({
routeMap: {
groupA: { duration: 1000, max: 1 },
groupB: { duration: 1000, max: 2 }
},
groupBy: (ctx) => (ctx.path === '/a' ? 'groupA' : 'groupB')
});

app.use(middleware);
app.use((ctx) => {
ctx.body = 'ok';
});

const server = app.callback();

// Group A - should block 2nd request
await request(server).get('/a').expect(200);
await request(server).get('/a').expect(429);

// Group B - should allow 2 requests
await request(server).get('/b').expect(200);
await request(server).get('/b').expect(200);
await request(server).get('/b').expect(429);
});

it('calls next() when no group matches', async () => {
const middleware = groupRateLimit({
routeMap: {
groupA: { duration: 1000, max: 1 }
},
groupBy: () => 'nonexistent'
});

app.use(middleware);
app.use((ctx) => {
ctx.body = 'ok';
});

await request(app.callback()).get('/x').expect(200);
});
});
Loading