-
Notifications
You must be signed in to change notification settings - Fork 3.9k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(api): Add API rate limiting NestJS guard #4910
Changes from 181 commits
748b194
4b47cce
75febc1
1e06a6d
15035d0
81ef5b7
d9d86d9
05962ea
975df32
e17ef8e
7618b62
1d5d7db
15324a5
642b046
acab424
167631f
9cde897
5b8a128
9f9cd55
72cf83b
e5e7357
e2a40cd
17a3bfb
de803e3
d8de11e
e746b41
ad7e156
06ce24b
c065cf0
310b877
7aa9adb
179586b
d2e6244
bfa1fe2
6ec497b
3a47eb4
bfcdc75
c4a5120
16703f6
4ea779d
976a36a
e3fb0c4
a7d672b
32932a6
cd1d75c
ea9f0f6
40e0470
68a0c79
34368ba
dc4abd0
944df01
2831766
2b86538
104a0e7
7fdb361
530ede7
d6146d4
bd103e5
5526b97
6537b71
232321e
36fa061
ece2df4
a7914e7
37546e8
ad84552
f63ae19
4acf5e5
9c6f8b0
e8085b0
c8a241b
622f0d6
dee2a2b
dc4e664
e48b042
106a8d8
1a8f58c
1d45ece
897119b
1087f5c
0ed6fe8
9bdaf9b
462f2e5
093a000
bdf05ca
ae80b28
2c609c4
137f6f1
d5d9203
7db1dba
760b6fb
30f2b5e
b8b72ed
c8c5c28
10ca9d9
7477b17
d941188
1c24411
7378ce4
e51ceca
114e26c
e6c718f
8e5f87a
5b391e9
20b74e8
545aa7e
fd9962b
7f150f9
9fd767f
925aefa
f6b6098
3fa6dff
f1835b6
5551104
e3b5bf9
5e2affb
359fed9
95f2ab6
0ac42cc
8c11c8a
0f9b163
03ba15a
bc848ef
5755611
c018948
dfb6c36
29489c7
b83edde
c9b89f9
5905b2a
507a517
e1ea93e
ede3228
a34a946
d9721ab
8615e7e
e3f3d9e
0e8257c
be1ca1e
7b2264a
a505889
31de2ff
301ca33
dd8ccd1
f3c288b
223aee1
97c6c0d
752ade9
c9c8faf
6c0bd50
d3a04e5
8a86a7f
a428fe6
d5ab65f
1d7a289
0509da9
deb01cf
34ea4e6
ff1955a
0c18892
3d83c03
088cfae
8603730
5e91dc3
b8ce7a4
8255c3e
7dc1352
4e84a20
04b99f9
9617a9f
64d6e4e
b625403
ef45dea
69360ed
ac45a0e
3dc6a9d
fab4042
ff165e3
7edb9eb
c9ae745
650eb3a
963a041
8781404
7d63579
6af53c1
fd05a99
8d53cf2
588933c
d987263
001a40a
828b9ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './throttler.decorator'; | ||
export * from './throttler.guard'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
import { Reflector } from '@nestjs/core'; | ||
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared'; | ||
|
||
// eslint-disable-next-line @typescript-eslint/naming-convention | ||
export const ThrottlerCategory = Reflector.createDecorator<ApiRateLimitCategoryEnum>(); | ||
|
||
// eslint-disable-next-line @typescript-eslint/naming-convention | ||
export const ThrottlerCost = Reflector.createDecorator<ApiRateLimitCostEnum>(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Custom decorators to specify custom costs and categories on both controllers and methods. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,309 @@ | ||
import { UserSession } from '@novu/testing'; | ||
import { RateLimitHeaderKeysEnum } from './throttler.guard'; | ||
import { expect } from 'chai'; | ||
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum, ApiServiceLevelEnum } from '@novu/shared'; | ||
|
||
const mockSingleCost = 1; | ||
const mockBulkCost = 5; | ||
const mockWindowDuration = 5; | ||
const mockBurstAllowance = 1; | ||
const mockMaximumFreeTrigger = 5; | ||
const mockMaximumFreeGlobal = 3; | ||
const mockMaximumUnlimitedTrigger = 10; | ||
const mockMaximumUnlimitedGlobal = 5; | ||
|
||
process.env.API_RATE_LIMIT_COST_SINGLE = `${mockSingleCost}`; | ||
process.env.API_RATE_LIMIT_COST_BULK = `${mockBulkCost}`; | ||
process.env.API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION = `${mockWindowDuration}`; | ||
process.env.API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE = `${mockBurstAllowance}`; | ||
process.env.API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER = `${mockMaximumFreeTrigger}`; | ||
process.env.API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL = `${mockMaximumFreeGlobal}`; | ||
process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER = `${mockMaximumUnlimitedTrigger}`; | ||
process.env.API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL = `${mockMaximumUnlimitedGlobal}`; | ||
|
||
describe('API Rate Limiting', () => { | ||
let session: UserSession; | ||
const pathPrefix = '/v1/rate-limiting'; | ||
let request: ( | ||
path: string, | ||
authHeader?: string | ||
) => Promise<Awaited<ReturnType<typeof UserSession.prototype.testAgent.get>>>; | ||
|
||
beforeEach(async () => { | ||
process.env.IS_API_RATE_LIMITING_ENABLED = 'true'; | ||
|
||
session = new UserSession(); | ||
await session.initialize(); | ||
|
||
request = (path: string, authHeader = `ApiKey ${session.apiKey}`) => | ||
session.testAgent.get(path).set('authorization', authHeader); | ||
}); | ||
|
||
describe('Feature Flag', () => { | ||
it('should set rate limit headers when the Feature Flag is enabled', async () => { | ||
process.env.IS_API_RATE_LIMITING_ENABLED = 'true'; | ||
const response = await request(pathPrefix + '/no-category-no-cost'); | ||
|
||
expect(response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT.toLowerCase()]).to.exist; | ||
}); | ||
|
||
it('should NOT set rate limit headers when the Feature Flag is disabled', async () => { | ||
process.env.IS_API_RATE_LIMITING_ENABLED = 'false'; | ||
const response = await request(pathPrefix + '/no-category-no-cost'); | ||
|
||
expect(response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT.toLowerCase()]).not.to.exist; | ||
}); | ||
}); | ||
|
||
describe('Allowed Authentication Security Schemes', () => { | ||
it('should set rate limit headers when ApiKey security scheme is used to authenticate', async () => { | ||
const response = await request(pathPrefix + '/no-category-no-cost', `ApiKey ${session.apiKey}`); | ||
|
||
expect(response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT.toLowerCase()]).to.exist; | ||
}); | ||
|
||
it('should NOT set rate limit headers when a Bearer security scheme is used to authenticate', async () => { | ||
const response = await request(pathPrefix + '/no-category-no-cost', session.token); | ||
|
||
expect(response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT.toLowerCase()]).not.to.exist; | ||
}); | ||
|
||
it('should NOT set rate limit headers when NO authorization header is present', async () => { | ||
const response = await request(pathPrefix + '/no-category-no-cost', ''); | ||
|
||
expect(response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT.toLowerCase()]).not.to.exist; | ||
}); | ||
}); | ||
|
||
describe('RateLimit-Policy', () => { | ||
const testParams = [ | ||
{ name: 'limit', expected: `${mockMaximumUnlimitedGlobal * mockWindowDuration}` }, | ||
{ name: 'w', expected: `w=${mockWindowDuration}` }, | ||
{ | ||
name: 'burst', | ||
expected: `burst=${mockMaximumUnlimitedGlobal * (1 + mockBurstAllowance) * mockWindowDuration}`, | ||
}, | ||
{ name: 'comment', expected: 'comment="token bucket"' }, | ||
{ name: 'category', expected: `category="${ApiRateLimitCategoryEnum.GLOBAL}"` }, | ||
{ name: 'cost', expected: `cost="${ApiRateLimitCostEnum.SINGLE}"` }, | ||
]; | ||
|
||
testParams.forEach(({ name, expected }) => { | ||
it(`should include the ${name} parameter`, async () => { | ||
const response = await request(pathPrefix + '/no-category-no-cost'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(expected); | ||
}); | ||
}); | ||
|
||
it('should separate the params with a semicolon', async () => { | ||
const response = await request(pathPrefix + '/no-category-no-cost'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader.split(';')).to.have.lengthOf(testParams.length); | ||
}); | ||
}); | ||
|
||
describe('Rate Limit Decorators', () => { | ||
describe('Controller WITHOUT Decorators', () => { | ||
const controllerPathPrefix = '/v1/rate-limiting'; | ||
|
||
it('should use the global category for an endpoint WITHOUT category decorator', async () => { | ||
const response = await request(controllerPathPrefix + '/no-category-no-cost'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`); | ||
}); | ||
|
||
it('should use the single cost for an endpoint WITHOUT cost decorator', async () => { | ||
const response = await request(controllerPathPrefix + '/no-category-no-cost'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`); | ||
}); | ||
}); | ||
|
||
describe('Controller WITH Decorators', () => { | ||
const controllerPathPrefix = '/v1/rate-limiting-trigger-bulk'; | ||
|
||
it('should use the category decorator defined on the controller for an endpoint WITHOUT category decorator', async () => { | ||
const response = await request(controllerPathPrefix + '/no-category-no-cost-override'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.TRIGGER}"`); | ||
}); | ||
|
||
it('should use the category decorator defined on the controller for an endpoint WITHOUT cost decorator', async () => { | ||
const response = await request(controllerPathPrefix + '/no-category-no-cost-override'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.BULK}"`); | ||
}); | ||
|
||
it('should override the category decorator defined on the controller for an endpoint WITH cost decorator', async () => { | ||
const response = await request(controllerPathPrefix + '/no-category-single-cost-override'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(`cost="${ApiRateLimitCostEnum.SINGLE}"`); | ||
}); | ||
|
||
it('should override the category decorator defined on the controller for an endpoint WITH category decorator', async () => { | ||
const response = await request(controllerPathPrefix + '/global-category-no-cost-override'); | ||
const policyHeader = response.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_POLICY.toLowerCase()]; | ||
|
||
expect(policyHeader).to.contain(`category="${ApiRateLimitCategoryEnum.GLOBAL}"`); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('API Rate Limit Scenarios', () => { | ||
type TestCase = { | ||
name: string; | ||
requests: { path: string; count: number }[]; | ||
expectedStatus: number; | ||
expectedLimit: number; | ||
expectedCost: number; | ||
expectedReset: number; | ||
expectedRetryAfter?: number; | ||
expectedThrottledRequests: number; | ||
setupTest?: (userSession: UserSession) => Promise<void>; | ||
}; | ||
|
||
const testCases: TestCase[] = [ | ||
{ | ||
name: 'single trigger endpoint request', | ||
requests: [{ path: '/trigger-category-single-cost', count: 1 }], | ||
expectedStatus: 200, | ||
expectedLimit: mockMaximumUnlimitedTrigger, | ||
expectedCost: mockSingleCost * 1, | ||
expectedReset: 5, | ||
expectedThrottledRequests: 0, | ||
}, | ||
{ | ||
name: 'no category no cost endpoint request', | ||
requests: [{ path: '/no-category-no-cost', count: 1 }], | ||
expectedStatus: 200, | ||
expectedLimit: mockMaximumUnlimitedGlobal, | ||
expectedCost: mockSingleCost * 1, | ||
expectedReset: 5, | ||
expectedThrottledRequests: 0, | ||
}, | ||
{ | ||
name: 'single trigger request with service level specified on organization ', | ||
requests: [{ path: '/trigger-category-single-cost', count: 1 }], | ||
expectedStatus: 200, | ||
expectedLimit: mockMaximumFreeTrigger, | ||
expectedCost: mockSingleCost * 1, | ||
expectedReset: 5, | ||
expectedThrottledRequests: 0, | ||
async setupTest(userSession) { | ||
await userSession.updateOrganizationServiceLevel(ApiServiceLevelEnum.FREE); | ||
}, | ||
}, | ||
{ | ||
name: 'single trigger request with maximum rate limit specified on environment', | ||
requests: [{ path: '/trigger-category-single-cost', count: 1 }], | ||
expectedStatus: 200, | ||
expectedLimit: 60, | ||
expectedCost: mockSingleCost * 1, | ||
expectedReset: 5, | ||
expectedThrottledRequests: 0, | ||
async setupTest(userSession) { | ||
await userSession.updateEnvironmentApiRateLimits({ [ApiRateLimitCategoryEnum.TRIGGER]: 60 }); | ||
}, | ||
}, | ||
{ | ||
name: 'combination of single trigger and single global endpoint request', | ||
requests: [ | ||
{ path: '/trigger-category-single-cost', count: 20 }, | ||
{ path: '/global-category-single-cost', count: 100 }, | ||
], | ||
expectedStatus: 429, | ||
expectedLimit: mockMaximumUnlimitedGlobal, | ||
expectedCost: mockSingleCost * 100, | ||
expectedReset: 5, | ||
expectedRetryAfter: 5, | ||
expectedThrottledRequests: 50, | ||
}, | ||
]; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scenarios testing combinations of different endpoints being requested. |
||
|
||
testCases | ||
.map( | ||
({ | ||
name, | ||
requests, | ||
expectedStatus, | ||
expectedLimit, | ||
expectedCost, | ||
expectedReset, | ||
expectedRetryAfter, | ||
expectedThrottledRequests, | ||
setupTest, | ||
}) => { | ||
return () => { | ||
describe(`${expectedStatus === 429 ? 'Throttled' : 'Allowed'} ${name}`, () => { | ||
let lastResponse: ReturnType<typeof UserSession.prototype.testAgent.get>; | ||
let throttledResponseCount = 0; | ||
const throttledResponseCountTolerance = 0.05; | ||
const expectedWindowLimit = expectedLimit * mockWindowDuration; | ||
const expectedBurstLimit = expectedWindowLimit * (1 + mockBurstAllowance); | ||
const expectedRemaining = Math.max(0, expectedBurstLimit - expectedCost); | ||
|
||
before(async () => { | ||
setupTest && (await setupTest(session)); | ||
for (const { path, count } of requests) { | ||
for (let index = 0; index < count; index++) { | ||
const response = await request(pathPrefix + path); | ||
lastResponse = response; | ||
|
||
if (response.statusCode === 429) { | ||
throttledResponseCount++; | ||
} | ||
} | ||
} | ||
}); | ||
|
||
it(`should return a ${expectedStatus} status code`, async () => { | ||
expect(lastResponse.statusCode).to.equal(expectedStatus); | ||
}); | ||
|
||
it(`should return a ${RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT} header of ${expectedWindowLimit}`, async () => { | ||
expect(lastResponse.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_LIMIT.toLowerCase()]).to.equal( | ||
`${expectedWindowLimit}` | ||
); | ||
}); | ||
|
||
it(`should return a ${RateLimitHeaderKeysEnum.RATE_LIMIT_REMAINING} header of ${expectedRemaining}`, async () => { | ||
expect(lastResponse.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_REMAINING.toLowerCase()]).to.equal( | ||
`${expectedRemaining}` | ||
); | ||
}); | ||
|
||
it(`should return a ${RateLimitHeaderKeysEnum.RATE_LIMIT_RESET} header of ${expectedReset}`, async () => { | ||
expect(lastResponse.headers[RateLimitHeaderKeysEnum.RATE_LIMIT_RESET.toLowerCase()]).to.equal( | ||
`${expectedReset}` | ||
); | ||
}); | ||
|
||
it(`should return a ${RateLimitHeaderKeysEnum.RETRY_AFTER} header of ${expectedRetryAfter}`, async () => { | ||
expect(lastResponse.headers[RateLimitHeaderKeysEnum.RETRY_AFTER.toLowerCase()]).to.equal( | ||
expectedRetryAfter && `${expectedRetryAfter}` | ||
); | ||
}); | ||
|
||
const expectedMinThrottled = Math.floor( | ||
expectedThrottledRequests * (1 - throttledResponseCountTolerance) | ||
); | ||
const expectedMaxThrottled = Math.ceil(expectedThrottledRequests * (1 + throttledResponseCountTolerance)); | ||
it(`should have between ${expectedMinThrottled} and ${expectedMaxThrottled} requests throttled`, async () => { | ||
expect(throttledResponseCount).to.be.greaterThanOrEqual(expectedMinThrottled); | ||
expect(throttledResponseCount).to.be.lessThanOrEqual(expectedMaxThrottled); | ||
}); | ||
}); | ||
}; | ||
} | ||
) | ||
.forEach((testCase) => testCase()); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Placing before the idempotency interceptor so that idempotent requests are still subject to rate limiting.