Skip to content

Commit

Permalink
feat(api): Add evaluate api rate limit use case (#4844)
Browse files Browse the repository at this point in the history
* 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
rifont authored Nov 28, 2023
1 parent 5605072 commit ccd52a2
Show file tree
Hide file tree
Showing 57 changed files with 1,403 additions and 225 deletions.
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@sentry/node": "^7.40.0",
"@sentry/tracing": "^7.40.0",
"@types/newrelic": "^9.14.0",
"@upstash/ratelimit": "^0.4.4",
"axios": "^1.3.3",
"bcrypt": "^5.0.0",
"body-parser": "^1.20.0",
Expand Down
16 changes: 15 additions & 1 deletion apps/api/src/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ REDIS_CACHE_FAMILY=
REDIS_CACHE_KEY_PREFIX=
REDIS_CACHE_ENABLE_AUTOPIPELINING=true

IS_API_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
ELASTICACHE_CLUSTER_SERVICE_HOST=
ELASTICACHE_CLUSTER_SERVICE_PORT=
Expand Down Expand Up @@ -68,3 +67,18 @@ LAUNCH_DARKLY_SDK_KEY=

IS_API_IDEMPOTENCY_ENABLED=false
AUTO_CREATE_INDEXES=true

IS_API_RATE_LIMITING_ENABLED=false
API_RATE_LIMIT_COST_SINGLE=
API_RATE_LIMIT_COST_BULK=
API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
16 changes: 15 additions & 1 deletion apps/api/src/.env.production
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ REDIS_PORT=6379
REDIS_PREFIX=
REDIS_DB_INDEX=2

IS_API_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
ELASTICACHE_CLUSTER_SERVICE_HOST=
ELASTICACHE_CLUSTER_SERVICE_PORT=
Expand Down Expand Up @@ -58,3 +57,18 @@ LAUNCH_DARKLY_SDK_KEY=
IS_API_IDEMPOTENCY_ENABLED=false
## This value should be set to true if it is the first time you are running the with the database
AUTO_CREATE_INDEXES=false

IS_API_RATE_LIMITING_ENABLED=false
API_RATE_LIMIT_COST_SINGLE=
API_RATE_LIMIT_COST_BULK=
API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
16 changes: 15 additions & 1 deletion apps/api/src/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ REDIS_CACHE_FAMILY=
REDIS_CACHE_KEY_PREFIX=
REDIS_CACHE_ENABLE_AUTOPIPELINING=false

IS_API_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
ELASTICACHE_CLUSTER_SERVICE_HOST=
ELASTICACHE_CLUSTER_SERVICE_PORT=
Expand Down Expand Up @@ -93,3 +92,18 @@ NOVU_SMS_INTEGRATION_SENDER=1234567890

IS_API_IDEMPOTENCY_ENABLED=true
AUTO_CREATE_INDEXES=true

IS_API_RATE_LIMITING_ENABLED=false
API_RATE_LIMIT_COST_SINGLE=
API_RATE_LIMIT_COST_BULK=
API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
16 changes: 15 additions & 1 deletion apps/api/src/.example.env
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ REDIS_CACHE_FAMILY=
REDIS_CACHE_KEY_PREFIX=
REDIS_CACHE_ENABLE_AUTOPIPELINING=

IS_API_RATE_LIMITING_ENABLED=false
IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false
REDIS_CLUSTER_SERVICE_HOST=
REDIS_CLUSTER_SERVICE_PORT=
Expand Down Expand Up @@ -63,3 +62,18 @@ INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY=
LOGGING_LEVEL=info

LAUNCH_DARKLY_SDK_KEY=

IS_API_RATE_LIMITING_ENABLED=false
API_RATE_LIMIT_COST_SINGLE=
API_RATE_LIMIT_COST_BULK=
API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE=
API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER=
API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL=
API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER=
API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION=
API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL=
4 changes: 2 additions & 2 deletions apps/api/src/app/organization/e2e/create-organization.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MemberRepository, OrganizationRepository } from '@novu/dal';
import { UserSession } from '@novu/testing';
import { ApiServiceLevelTypeEnum, MemberRoleEnum } from '@novu/shared';
import { ApiServiceLevelEnum, MemberRoleEnum } from '@novu/shared';
import { expect } from 'chai';

describe('Create Organization - /organizations (POST)', async () => {
Expand Down Expand Up @@ -53,7 +53,7 @@ describe('Create Organization - /organizations (POST)', async () => {
const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201);
const dbOrganization = await organizationRepository.findById(body.data._id);

expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelTypeEnum.FREE);
expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelEnum.FREE);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Inject, Injectable, Scope } from '@nestjs/common';
import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal';
import { ApiServiceLevelTypeEnum, MemberRoleEnum } from '@novu/shared';
import { ApiServiceLevelEnum, MemberRoleEnum } from '@novu/shared';
import { AnalyticsService } from '@novu/application-generic';

import { CreateEnvironmentCommand } from '../../../environments/usecases/create-environment/create-environment.command';
Expand Down Expand Up @@ -36,7 +36,7 @@ export class CreateOrganization {
const createdOrganization = await this.organizationRepository.create({
logo: command.logo,
name: command.name,
apiServiceLevel: ApiServiceLevelTypeEnum.FREE,
apiServiceLevel: ApiServiceLevelEnum.FREE,
});

await this.addMemberUsecase.execute(
Expand Down
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;
}
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])
);
});
});
});
Loading

1 comment on commit ccd52a2

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.