Skip to content
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 automatic quota throttling #5485

Merged
merged 40 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f508668
fix(web): Rename Billing plans to Billing
rifont May 2, 2024
fc456ba
refactor(shared): Tidy up builders and add comments
rifont May 2, 2024
c4c9323
chore(shared): Add feature flags for quota limiting
rifont May 2, 2024
322e892
test(shared): Update key builder test
rifont May 2, 2024
caeaac1
fix(api): Use new ff key
rifont May 2, 2024
90bf53d
chore(shared): Remove uncessary out-of-line exports
rifont May 2, 2024
34b47a8
chore(app-gen): Fix typo
rifont May 2, 2024
f295e9e
refactor(app-gen): Separate file for key builder identifiers
rifont May 2, 2024
5f64d9f
chore: Update per PR comments
rifont May 6, 2024
8caabdb
fix: App gen imports
rifont May 7, 2024
8af48e0
fix: testing controller import
rifont May 7, 2024
61f823f
fix: imports
rifont May 7, 2024
3d30dfd
fix: naming
rifont May 13, 2024
2ff0773
chore: Upgrade to Redlock with typescript
rifont May 13, 2024
76d0395
chore: Remove unused decorator
rifont May 13, 2024
793f4d9
feat: Add locking to cached entity interceptor
rifont May 13, 2024
ff336bb
test: fix create-usage-records test
rifont May 13, 2024
9044b66
test: fix customer subscription updated test
rifont May 13, 2024
1ed11f8
test(api): add evaluate-event-resource-limit tests
rifont May 13, 2024
9326c64
chore: remove redundant resource guard
rifont May 13, 2024
1b58c90
feat(api): Add safe EE import for quota throttler
rifont May 13, 2024
0d6bfdf
chore: Add nestjs imports for billing
rifont May 13, 2024
9daeb62
test(api): Add ci ee flag to api ee tests
rifont May 13, 2024
19ac014
test(api): Use enums for test
rifont May 13, 2024
29e1cf8
test(api): Add quota throttler guard test
rifont May 13, 2024
9af0be5
chore: update submodule
rifont May 13, 2024
1729934
revert(api): Accidental package script change
rifont May 13, 2024
9483c1a
chore: Update submodule
rifont May 13, 2024
3c98b6c
chore: submodule
rifont May 13, 2024
97f64cb
chore: submodule update
rifont May 13, 2024
e0c4cee
chore: update submodule
rifont May 14, 2024
0e97783
Merge branch 'next' into ent-5-api-ingress-blocking-cache-keys
rifont May 14, 2024
0fb97d1
test(web): Update cypress tests
rifont May 14, 2024
f416326
chore: update submodule
rifont May 14, 2024
3371125
fix(billing): Add mongoose dependency
rifont May 14, 2024
76df417
test(web): Update billing widget assertion
rifont May 14, 2024
57c7eeb
fix(app-gen): Separate method resolution and cache set
rifont May 15, 2024
361a8cb
chore: update submodule
rifont May 15, 2024
7327bb4
fix(app-gen): unlock only on error
rifont May 15, 2024
b016f39
Merge branch 'next' into ent-5-api-ingress-blocking-cache-keys
rifont May 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ describe('Resource Limiting', () => {

describe('Event resource', () => {
it('should block the request when the Feature Flag is enabled', async () => {
process.env.IS_EVENT_RESOURCE_LIMITING_ENABLED = 'true';
process.env.IS_EVENT_QUOTA_LIMITING_ENABLED = 'true';
const response = await request(pathEvent);

expect(response.status).to.equal(402);
});

it('should NOT block the request when the Feature Flag is disabled', async () => {
process.env.IS_EVENT_RESOURCE_LIMITING_ENABLED = 'false';
process.env.IS_EVENT_QUOTA_LIMITING_ENABLED = 'false';
const response = await request(pathEvent);

expect(response.status).to.equal(200);
Expand All @@ -56,14 +56,14 @@ describe('Resource Limiting', () => {

describe('Default resources (no decorator)', () => {
it('should handle the request when the Feature Flag is enabled', async () => {
process.env.IS_EVENT_RESOURCE_LIMITING_ENABLED = 'true';
process.env.IS_EVENT_QUOTA_LIMITING_ENABLED = 'true';
const response = await request(pathDefault);

expect(response.status).to.equal(200);
});

it('should handle the request when the Feature Flag is disabled', async () => {
process.env.IS_EVENT_RESOURCE_LIMITING_ENABLED = 'false';
process.env.IS_EVENT_QUOTA_LIMITING_ENABLED = 'false';
const response = await request(pathDefault);

expect(response.status).to.equal(200);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { IJwtPayload, FeatureFlagsKeysEnum, ResourceEnum } from '@novu/shared';
import { Observable } from 'rxjs';
import { ResourceCategory } from './resource-throttler.decorator';

// eslint-disable-next-line max-len
export const THROTTLED_EXCEPTION_MESSAGE = `You have exceeded the number of allowed requests for this resource. Please visit ${process.env.FRONT_BASE_URL}/settings/billing to upgrade your plan.`;

/**
Expand Down Expand Up @@ -47,7 +48,7 @@ export class ResourceThrottlerInterceptor implements NestInterceptor {
organizationId,
environmentId: 'system',
userId: 'system',
key: FeatureFlagsKeysEnum.IS_EVENT_RESOURCE_LIMITING_ENABLED,
key: FeatureFlagsKeysEnum.IS_EVENT_QUOTA_LIMITING_ENABLED,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Renaming to align with intended purpose, which is to limit resource quotas.

})
);

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/nav/SettingsNavMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const SettingsNavMenu: React.FC = () => {
testId="side-nav-settings-branding-link"
></NavMenuLinkButton>
<NavMenuLinkButton
label="Billing plans"
label="Billing"
Copy link
Contributor Author

@rifont rifont May 2, 2024

Choose a reason for hiding this comment

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

This was a regression during Information Architecture feature development.

isVisible
icon={<IconCreditCard />}
link={ROUTES.BILLING}
Expand Down
3 changes: 2 additions & 1 deletion libs/shared/src/types/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export enum FeatureFlagsKeysEnum {
IS_INFORMATION_ARCHITECTURE_ENABLED = 'IS_INFORMATION_ARCHITECTURE_ENABLED',
IS_BILLING_REVERSE_TRIAL_ENABLED = 'IS_BILLING_REVERSE_TRIAL_ENABLED',
IS_HUBSPOT_ONBOARDING_ENABLED = 'IS_HUBSPOT_ONBOARDING_ENABLED',
IS_EVENT_RESOURCE_LIMITING_ENABLED = 'IS_EVENT_RESOURCE_LIMITING_ENABLED',
IS_QUOTA_LIMITING_ENABLED = 'IS_QUOTA_LIMITING_ENABLED',
IS_EVENT_QUOTA_LIMITING_ENABLED = 'IS_EVENT_QUOTA_LIMITING_ENABLED',
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createHash as createHashCrypto } from 'crypto';

export const createHash = (str: string): string => {
const hash = createHashCrypto('sha256');
hash.update(str);

return hash.digest('hex');
};
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Separating into a new file to keep independent from the key builder use-cases.

Copy link
Contributor

Choose a reason for hiding this comment

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

Love it! , i wanted to separate this one and another 'createHmac' but was not able to do it because we had changes in multiple PRs.
IMO this one is a generic function and can live outside of the cache scope, wdyt?

Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {
buildEnvironmentByApiKey,
buildKeyById,
buildNotificationTemplateIdentifierKey,
buildNotificationTemplateKey,
buildSubscriberKey,
Expand All @@ -11,6 +10,7 @@ import {
CacheKeyPrefixEnum,
IdentifierPrefixEnum,
OrgScopePrefixEnum,
buildUnscopedKey,
} from './shared';

describe('Key builder for entities', () => {
Expand Down Expand Up @@ -52,7 +52,7 @@ describe('Key builder for entities', () => {
const identifierPrefix = IdentifierPrefixEnum.SUBSCRIBER_ID;
const identifier = '123';
const expectedKey = `{${type}:${keyEntity}:${identifierPrefix}=${identifier}}`;
const actualKey = buildKeyById({
const actualKey = buildUnscopedKey({
type,
keyEntity,
identifierPrefix,
Expand Down
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This file only has internal API changes, no changes to external exports.

Original file line number Diff line number Diff line change
@@ -1,173 +1,176 @@
import { createHash } from './crypto';
import {
BLUEPRINT_IDENTIFIER,
buildCommonEnvironmentKey,
buildCommonKey,
buildKeyById,
buildEnvironmentScopedKeyById,
buildUnscopedKey,
CacheKeyPrefixEnum,
CacheKeyTypeEnum,
IdentifierPrefixEnum,
OrgScopePrefixEnum,
ServiceConfigIdentifierEnum,
buildEnvironmentScopedKey,
buildOrganizationScopedKeyById,
buildOrganizationScopedKey,
buildServiceConfigKey,
} from './shared';
import { createHash as createHashCrypto } from 'crypto';

const buildSubscriberKey = ({
export const buildSubscriberKey = ({
subscriberId,
_environmentId,
}: {
subscriberId: string;
_environmentId: string;
}): string =>
buildCommonKey({
buildEnvironmentScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.SUBSCRIBER,
environmentId: _environmentId,
identifierPrefix: IdentifierPrefixEnum.SUBSCRIBER_ID,
identifier: subscriberId,
});

const buildVariablesKey = ({
export const buildVariablesKey = ({
_environmentId,
_organizationId,
}: {
_environmentId: string;
_organizationId: string;
}): string =>
buildCommonEnvironmentKey({
buildEnvironmentScopedKey({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.WORKFLOW_VARIABLES,
environmentId: _environmentId,
});

const buildUserKey = ({ _id }: { _id: string }): string =>
buildKeyById({
export const buildUserKey = ({ _id }: { _id: string }): string =>
buildUnscopedKey({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.USER,
identifier: _id,
});

const buildNotificationTemplateKey = ({
export const buildNotificationTemplateKey = ({
_id,
_environmentId,
}: {
_id: string;
_environmentId: string;
}): string =>
buildCommonKey({
buildEnvironmentScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.NOTIFICATION_TEMPLATE,
environmentId: _environmentId,
identifierPrefix: IdentifierPrefixEnum.ID,
identifier: _id,
});

const buildNotificationTemplateIdentifierKey = ({
export const buildNotificationTemplateIdentifierKey = ({
templateIdentifier,
_environmentId,
}: {
templateIdentifier: string;
_environmentId: string;
}): string =>
buildCommonKey({
buildEnvironmentScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.NOTIFICATION_TEMPLATE,
environmentId: _environmentId,
identifierPrefix: IdentifierPrefixEnum.TEMPLATE_IDENTIFIER,
identifier: templateIdentifier,
});

const buildEnvironmentByApiKey = ({ apiKey }: { apiKey: string }): string =>
buildKeyById({
export const buildEnvironmentByApiKey = ({
apiKey,
}: {
apiKey: string;
}): string =>
buildUnscopedKey({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.ENVIRONMENT_BY_API_KEY,
identifier: apiKey,
identifierPrefix: IdentifierPrefixEnum.API_KEY,
});

const buildGroupedBlueprintsKey = (environmentId: string): string =>
buildCommonKey({
export const buildGroupedBlueprintsKey = (environmentId: string): string =>
buildEnvironmentScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.GROUPED_BLUEPRINTS,
environmentIdPrefix: OrgScopePrefixEnum.ORGANIZATION_ID,
environmentId: environmentId,
environmentId,
identifierPrefix: IdentifierPrefixEnum.GROUPED_BLUEPRINT,
identifier: BLUEPRINT_IDENTIFIER,
});

const createHash = (apiKey: string): string => {
const hash = createHashCrypto('sha256');
hash.update(apiKey);

return hash.digest('hex');
};

const buildAuthServiceKey = ({ apiKey }: { apiKey: string }): string => {
export const buildAuthServiceKey = ({ apiKey }: { apiKey: string }): string => {
const apiKeyHash = createHash(apiKey);

return buildKeyById({
return buildUnscopedKey({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.AUTH_SERVICE,
identifier: apiKeyHash,
identifierPrefix: IdentifierPrefixEnum.API_KEY,
});
};

const buildMaximumApiRateLimitKey = ({
export const buildMaximumApiRateLimitKey = ({
apiRateLimitCategory,
_environmentId,
}: {
apiRateLimitCategory: string;
_environmentId: string;
}): string =>
buildCommonKey({
buildEnvironmentScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.MAXIMUM_API_RATE_LIMIT,
environmentId: _environmentId,
identifierPrefix: IdentifierPrefixEnum.API_RATE_LIMIT_CATEGORY,
identifier: apiRateLimitCategory,
});

const buildEvaluateApiRateLimitKey = ({
export const buildEvaluateApiRateLimitKey = ({
apiRateLimitCategory,
_environmentId,
}: {
apiRateLimitCategory: string;
_environmentId: string;
}): string =>
buildCommonKey({
buildEnvironmentScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.EVALUATE_API_RATE_LIMIT,
environmentId: _environmentId,
identifierPrefix: IdentifierPrefixEnum.API_RATE_LIMIT_CATEGORY,
identifier: apiRateLimitCategory,
});

const buildServiceConfigKey = (
identifier: ServiceConfigIdentifierEnum
): string =>
buildKeyById({
export const buildEventUsageKey = ({
_organizationId,
resourceType,
periodStart,
periodEnd,
}: {
_organizationId: string;
resourceType: string;
periodStart: number;
periodEnd: number;
}): string =>
buildOrganizationScopedKeyById({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.USAGE,
identifierPrefix: IdentifierPrefixEnum.RESOURCE_TYPE,
identifier: `${resourceType}_${periodStart}_${periodEnd}`,
organizationId: _organizationId,
});

export const buildSubscriptionKey = ({
organizationId,
}: {
organizationId: string;
}): string =>
buildOrganizationScopedKey({
type: CacheKeyTypeEnum.ENTITY,
keyEntity: CacheKeyPrefixEnum.SERVICE_CONFIG,
identifierPrefix: IdentifierPrefixEnum.SERVICE_CONFIG,
identifier,
keyEntity: CacheKeyPrefixEnum.SUBSCRIPTION,
organizationId,
});

const buildServiceConfigApiRateLimitMaximumKey = (): string =>
export const buildServiceConfigApiRateLimitMaximumKey = (): string =>
buildServiceConfigKey(
ServiceConfigIdentifierEnum.API_RATE_LIMIT_SERVICE_MAXIMUM
);

export {
buildUserKey,
buildSubscriberKey,
buildNotificationTemplateKey,
buildNotificationTemplateIdentifierKey,
buildEnvironmentByApiKey,
buildKeyById,
buildGroupedBlueprintsKey,
buildAuthServiceKey,
buildMaximumApiRateLimitKey,
buildEvaluateApiRateLimitKey,
buildServiceConfigApiRateLimitMaximumKey,
buildVariablesKey,
};
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's a bit of a code smell to update here, so we instead export each of the required builders in-line.

Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
export * from './entities';
export * from './queries';
export * from './shared';