-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(api): Add evaluate api rate limit use case (#4844)
* feat(dal, shared, api): Add DAL fields for rate limiting * test(api): Add tests for rate limit fields * test(api): Use enum for apiServiceLevel test assertion * fix(dal): Use api prefix for rate limits to differentiate from other future rate limited protocols * fix(dal): Update category enum to also include api prefix * fix(dal): Make apiRateLimits subdocument optional * feat(shared): Add API rate limiting cache key builder * fix(dal): Add fallback unlimited tier * feat(shared): Add rate limiting constants * feat(api): Add get rate limit use case * fix(api): Fix import path * test(application-generic): Refactor mock cache service into separate file * test(api): Add unit tests for get-api-rate-limit use-case * fix(api): Remove unused LOG_CONTEXT declaration in get-api-rate-limit use case * feat(api): Add rate limiting module, add get-default-api-rate-limit use-case * feat(shared): Add types for env var format and platform rate limit map * refactor(api): Refactor get-api-rate-limit use-case to use the get-default-api-rate-limits use-case * refactor(api, shared): Rename api rate limiting interface for descriptiveness * refactor(api): Rename get-api-rate-limit use-case helper method for consistency * feat(api): Add logging to get-api-rate-limit use case * fix(shared): Add missing newline * fix(api): Typo * fix(api): Removed unused import in get-api-rate-limit use-case * refactor(api, application-generic): Rename max api rate limit cache key * feat(application-generic): Add evaluate api rate limit cache key builder * fix(shared): Remove redundant import rename * chore(shared): Remove redundant constant * refactor(shared): Create new rate limiting types folder * fix(shared): Remove redundant satisfies operator * chore(shared): Remove unused export in organization types * feat(shared): Add api rate limit configuration types and cosntants * feat(api): Add get-api-rate-limit-configuration use-case * refactor(api): Move get-api-rate-limit use case to rate-limiting module per PR feedback * fix(api): Add module import for get-api-rate-limit use case * fix(api): Remove redundant imports * feat(application-generic): Expose SADD and EVAL redis operations on cache service * feat(api): Add evaluate-api-rate-limit use cases * chore: Run pnpm install to resolve merge conflicts on lockfile * fix(api): Add better logging for rate limit evaluation error * test(api): Add tests for evaluate-api-rate-limit use-case * docs(api): Fix rate limit mock comment * docs(api): Add further clarification to rate limit evaluation mock * docs(api): Further mock redis eval clarification * fix(shared): Adjust typing of IApiRateLimitConfiguration to allow arbitrary configuration types * fix(shared): Rename refillInterval to windowDuration to align with rate limiting RFC nomenclature * feat(api): Expose refillRate, windowDuration, and burstLimit from evaluate-api-rate-limit use case * fix(api): Remove redundant ICacheService import * feat(api): Extract evaluate-api-rate-limit use-case typings * fix(application-generic): Use correct method params for eval, add mock client tests * chore(application-generic): Tidy up cache typings * feat(api): Return algorithm in rate limit execution * feat(api): Add support for bulk operation cost in rate limit execution * feat(api): Add variable cost rate limiting algorithm * feat(shared): Add default bulk cost for api rate limiting * feat(shared): Add bulk cost typing for api rate limiting * feat(api): Add support use-cases for rate limit evaluation * chore(api): Revert accidental changes max-rate-limit use case * feat(api): Add bulk support to evaluate rate limit use case * chore(api, dal, shared): Rename API Rate Limiting enums and typings for consistency * fix(shared): Export rate limit types from index * fix(api): Add get-max-rate-limit command back * fix(api): Update rate limit use case imports * fix(app-gen): Fix mock cache SADD implementation to match return val of set operations * fix(api): Fix import for get max rate limit use-case test * feat(api): Modify token bucket algorithm to allow for variable cost * chore(api): Tidy up algo logic * chore(api): Fix comments on algo * feat(api): Add local caching back to rate limit algo * chore(api): Refactor token bucket algorithm into separate use-case * feat(api): Add modified token bucket rate limiter * fix(api): Make cache client adapter static and add tests * fix(api): Fix use-case tests * fix(api, shared): Use snake_case enum values for rate limit configuration * fix(api): Fix evaluate rate limit import * fix(api): Use enum value * fix(api): Fix bad enum reference * fix(api): Fix test describe naming * feat(api): Add rate limit configuration environment variables * test(api): Fix burst limit calculation * fix(api): Make the createLimiter method functional * fix(api): Invalidate max rate limit cache entities when loading max limit config module * feat(app-gen): Add key builder for service config and tidy up base keygen locations * fix(api): Fix get-max-ratelimit usecase CachedEntity builder to use correct environment ID on * fix(api): Specify default limits on max-rate-limit class attribute * fix(api): Fix env vars in tests * fix(api): Consolidate algorithm into use-case * test(api): Add tests for variable-cost token bucket rate limiter * fix(api): Revert addition of variable-cost token bucket * fix(api): Address PR review comments
- Loading branch information
Showing
57 changed files
with
1,403 additions
and
225 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
13 changes: 13 additions & 0 deletions
13
...src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
import { IsDefined, IsEnum } from 'class-validator'; | ||
import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared'; | ||
import { EnvironmentCommand } from '../../../shared/commands/project.command'; | ||
|
||
export class EvaluateApiRateLimitCommand extends EnvironmentCommand { | ||
@IsDefined() | ||
@IsEnum(ApiRateLimitCategoryEnum) | ||
apiRateLimitCategory: ApiRateLimitCategoryEnum; | ||
|
||
@IsDefined() | ||
@IsEnum(ApiRateLimitCostEnum) | ||
apiRateLimitCost: ApiRateLimitCostEnum; | ||
} |
209 changes: 209 additions & 0 deletions
209
...pi/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,209 @@ | ||
import { Test } from '@nestjs/testing'; | ||
import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from './index'; | ||
import { UserSession } from '@novu/testing'; | ||
import { | ||
ApiRateLimitAlgorithmEnum, | ||
ApiRateLimitCategoryEnum, | ||
ApiRateLimitCostEnum, | ||
IApiRateLimitAlgorithm, | ||
IApiRateLimitCost, | ||
} from '@novu/shared'; | ||
import { expect } from 'chai'; | ||
import * as sinon from 'sinon'; | ||
import { GetApiRateLimitMaximum } from '../get-api-rate-limit-maximum'; | ||
import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config'; | ||
import { SharedModule } from '../../../shared/shared.module'; | ||
import { RateLimitingModule } from '../../rate-limiting.module'; | ||
import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config'; | ||
import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit'; | ||
|
||
const mockApiRateLimitAlgorithm: IApiRateLimitAlgorithm = { | ||
[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: 0.2, | ||
[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: 2, | ||
}; | ||
const mockApiRateLimitCost = ApiRateLimitCostEnum.SINGLE; | ||
const mockCost = 1; | ||
const mockApiRateLimitCostConfig: Partial<IApiRateLimitCost> = { | ||
[mockApiRateLimitCost]: mockCost, | ||
}; | ||
|
||
const mockMaxLimit = 10; | ||
const mockRemaining = 9; | ||
const mockReset = 1; | ||
const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; | ||
|
||
describe('EvaluateApiRateLimit', async () => { | ||
let useCase: EvaluateApiRateLimit; | ||
let session: UserSession; | ||
let getApiRateLimitMaximum: GetApiRateLimitMaximum; | ||
let getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig; | ||
let getApiRateLimitCostConfig: GetApiRateLimitCostConfig; | ||
let evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit; | ||
|
||
let getApiRateLimitMaximumStub: sinon.SinonStub; | ||
let getApiRateLimitAlgorithmConfigStub: sinon.SinonStub; | ||
let getApiRateLimitCostConfigStub: sinon.SinonStub; | ||
let evaluateTokenBucketRateLimitStub: sinon.SinonStub; | ||
|
||
beforeEach(async () => { | ||
const moduleRef = await Test.createTestingModule({ | ||
imports: [SharedModule, RateLimitingModule], | ||
}).compile(); | ||
|
||
session = new UserSession(); | ||
await session.initialize(); | ||
|
||
useCase = moduleRef.get<EvaluateApiRateLimit>(EvaluateApiRateLimit); | ||
getApiRateLimitMaximum = moduleRef.get<GetApiRateLimitMaximum>(GetApiRateLimitMaximum); | ||
getApiRateLimitAlgorithmConfig = moduleRef.get<GetApiRateLimitAlgorithmConfig>(GetApiRateLimitAlgorithmConfig); | ||
getApiRateLimitCostConfig = moduleRef.get<GetApiRateLimitCostConfig>(GetApiRateLimitCostConfig); | ||
evaluateTokenBucketRateLimit = moduleRef.get<EvaluateTokenBucketRateLimit>(EvaluateTokenBucketRateLimit); | ||
|
||
getApiRateLimitMaximumStub = sinon.stub(getApiRateLimitMaximum, 'execute').resolves(mockMaxLimit); | ||
getApiRateLimitAlgorithmConfigStub = sinon | ||
.stub(getApiRateLimitAlgorithmConfig, 'default') | ||
.value(mockApiRateLimitAlgorithm); | ||
getApiRateLimitCostConfigStub = sinon.stub(getApiRateLimitCostConfig, 'default').value(mockApiRateLimitCostConfig); | ||
evaluateTokenBucketRateLimitStub = sinon.stub(evaluateTokenBucketRateLimit, 'execute').resolves({ | ||
success: true, | ||
limit: mockMaxLimit, | ||
remaining: mockRemaining, | ||
reset: mockReset, | ||
}); | ||
}); | ||
|
||
afterEach(() => { | ||
getApiRateLimitMaximumStub.restore(); | ||
getApiRateLimitAlgorithmConfigStub.restore(); | ||
getApiRateLimitCostConfigStub.restore(); | ||
}); | ||
|
||
describe('Evaluation Values', () => { | ||
it('should return a boolean success value', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(typeof result.success).to.equal('boolean'); | ||
}); | ||
|
||
it('should return a positive limit', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.limit).to.be.greaterThan(0); | ||
}); | ||
|
||
it('should return a positive remaining tokens ', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.remaining).to.be.greaterThan(0); | ||
}); | ||
|
||
it('should return a positive reset', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.reset).to.be.greaterThan(0); | ||
}); | ||
}); | ||
|
||
describe('Static Values', () => { | ||
it('should return a string type algorithm value', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(typeof result.algorithm).to.equal('string'); | ||
}); | ||
|
||
it('should return the correct window duration', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.windowDuration).to.equal(mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]); | ||
}); | ||
}); | ||
|
||
describe('Computed Values', () => { | ||
it('should return the correct cost', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.cost).to.equal(mockApiRateLimitCostConfig[mockApiRateLimitCost]); | ||
}); | ||
|
||
it('should return the correct refill rate', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.refillRate).to.equal( | ||
mockMaxLimit * mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] | ||
); | ||
}); | ||
|
||
it('should return the correct burst limit', async () => { | ||
const result = await useCase.execute( | ||
EvaluateApiRateLimitCommand.create({ | ||
organizationId: session.organization._id, | ||
environmentId: session.environment._id, | ||
apiRateLimitCategory: mockApiRateLimitCategory, | ||
apiRateLimitCost: mockApiRateLimitCost, | ||
}) | ||
); | ||
|
||
expect(result.burstLimit).to.equal( | ||
mockMaxLimit * | ||
mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] * | ||
(1 + mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]) | ||
); | ||
}); | ||
}); | ||
}); |
Oops, something went wrong.
ccd52a2
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.
🎉 Published on https://dev.widget.novu.co as production
🚀 Deployed on https://6566414d2d35440d0cba968d--dev-widget-novu.netlify.app