Skip to content

Commit

Permalink
Merge branch 'next' into nv-5144-enforce-tier-duration-limits-for-dig…
Browse files Browse the repository at this point in the history
…est-and-delay-steps-in-code-first
  • Loading branch information
djabarovgeorge authored Jan 20, 2025
2 parents 3e57134 + f1cbf2d commit ed6f80a
Show file tree
Hide file tree
Showing 127 changed files with 4,166 additions and 1,769 deletions.
4 changes: 4 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
"subscriberpayloaddto",
"adresses",
"sdkerror",
"idempotancy",
"errordto",
"filtertopicsresponsedto",
"africas",
"idemp",
"africastalking",
"preferencechannels",
"Aland",
Expand Down
93 changes: 93 additions & 0 deletions apps/api/e2e/mock-http-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { HTTPClient, HTTPClientOptions } from '@novu/api/lib/http';

export class MockHTTPClient extends HTTPClient {
private mockResponses: Map<string, Array<{ response: Response; remaining: number }>> = new Map();
private recordedRequests: Array<{ request: Request; response: Response }> = [];

constructor(mockConfigs: MockConfig[] = [], options: HTTPClientOptions = {}) {
super(options);
this.initializeMockResponses(mockConfigs);
}

/**
* Initializes mock responses from the provided mock configurations.
* @param mockConfigs An array of mock configuration objects.
*/
private initializeMockResponses(mockConfigs: MockConfig[]) {
mockConfigs.forEach(({ baseUrl, path, method, responseCode, responseJson, times }) => {
const url = new URL(path, baseUrl).toString();
const response = new Response(JSON.stringify(responseJson), {
status: responseCode,
headers: { 'Content-Type': 'application/json' },
});

const parsedUrl = new URL(url);
const key = parsedUrl.pathname + method; // Use pathname instead of the full URL

if (!this.mockResponses.has(key)) {
this.mockResponses.set(key, []);
}

this.mockResponses.get(key)!.push({ response, remaining: times });
});
}

/**
* Overrides the request method to return mock responses.
* @param request The Request object containing the request details.
* @returns A Promise that resolves to the mock response or an error if no mocks are available.
*/
async request(request: Request): Promise<Response> {
const { url } = request;
const { method } = request;

// Parse the URL to get the pathname without query parameters
const parsedUrl = new URL(url);
const key = parsedUrl.pathname + method; // Use pathname instead of the full URL

if (this.mockResponses.has(key)) {
const responses = this.mockResponses.get(key)!;

for (let i = 0; i < responses.length; i += 1) {
const responseConfig = responses[i];
if (responseConfig.remaining > 0) {
responseConfig.remaining -= 1;

this.recordedRequests.push({ request, response: responseConfig.response });

if (responseConfig.remaining === 0) {
responses.splice(i, 1);
}

if (responses.length === 0) {
this.mockResponses.delete(key);
}

return responseConfig.response.clone();
}
}

this.mockResponses.delete(key);
throw new Error(`No remaining mock responses for ${parsedUrl.pathname} ${method}`);
}
throw new Error(`No remaining mock responses for ${key} Existing: ${Object.keys(this.mockResponses)} `);
}

/**
* Getter to access recorded requests and responses.
* @returns An array of recorded requests and their corresponding responses.
*/
getRecordedRequests(): Array<{ request: Request; response: Response }> {
return this.recordedRequests;
}
}

// eslint-disable-next-line @typescript-eslint/naming-convention
export interface MockConfig {
baseUrl: string;
path: string;
method: string;
responseCode: number;
responseJson: unknown;
times: number;
}
271 changes: 271 additions & 0 deletions apps/api/e2e/retry.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { Novu } from '@novu/api';
import { expect } from 'chai';
import { topicsList } from '@novu/api/funcs/topicsList';
import { FilterTopicsResponseDto } from '@novu/api/src/models/components/filtertopicsresponsedto';
import { expectSdkExceptionGeneric } from '../src/app/shared/helpers/e2e/sdk/e2e-sdk.helper';
import { MockHTTPClient } from './mock-http-server';
import { ErrorDto } from '../src/error-dto';

function getIdempotencyKeys(mockHTTPClient: MockHTTPClient) {
return mockHTTPClient
.getRecordedRequests()
.map((req) => req.request.headers)
.flatMap((headers) => (headers['Idempotency-Key'] ? [headers['Idempotency-Key']] : []))
.filter((key) => key !== undefined);
}

describe('Novu Node.js package - Retries and idempotency-key', () => {
it('should retry trigger and generate idempotency-key only once for request', async () => {
const mockHTTPClient = new MockHTTPClient([
{
baseUrl: BACKEND_URL,
path: TRIGGER_PATH,
responseCode: 500,
responseJson: buildErrorDto(TRIGGER_PATH, 'Server Exception', 500),
method: 'POST',
times: 3,
},
{
baseUrl: BACKEND_URL,
path: TRIGGER_PATH,
responseCode: 201,
responseJson: { acknowledged: true, transactionId: '1003', status: 'error' },
method: 'POST',
times: 1,
},
]);
novuClient = new Novu({
apiKey: 'fakeKey',
serverURL: BACKEND_URL,
httpClient: mockHTTPClient,
});

await novuClient.trigger({
name: 'fake-workflow',
to: { subscriberId: '123' },
payload: {},
});

const requestKeys = getIdempotencyRequestKeys(mockHTTPClient);
expect(hasAllEqual(requestKeys), JSON.stringify(requestKeys)).to.be.eq(true);
});

it('should generate different idempotency-key for each request', async () => {
const httpClient = new MockHTTPClient([
{
baseUrl: BACKEND_URL,
path: TRIGGER_PATH,
responseCode: 201,
responseJson: { acknowledged: true, transactionId: '1003', status: 'error' },
method: 'POST',
times: 2,
},
]);
novuClient = new Novu({
apiKey: 'fakeKey',
serverURL: BACKEND_URL,
httpClient,
});
await novuClient.trigger({ name: 'fake-workflow', to: { subscriberId: '123' }, payload: {} });
await novuClient.trigger({ name: 'fake-workflow', to: { subscriberId: '123' }, payload: {} });

const idempotencyRequestKeys = getIdempotencyRequestKeys(httpClient);
expect(new Set(idempotencyRequestKeys).size, JSON.stringify(idempotencyRequestKeys)).to.be.eq(2);
});

it('should retry on status 422 and idempotency-key should be the same for every retry', async () => {
const mockHTTPClient = new MockHTTPClient([
{
baseUrl: BACKEND_URL,
path: TRIGGER_PATH,
responseCode: 422,
responseJson: buildErrorDto(TRIGGER_PATH, 'Unprocessable Content', 422),
method: 'POST',
times: 3,
},
{
baseUrl: BACKEND_URL,
path: TRIGGER_PATH,
responseCode: 201,
responseJson: { acknowledged: true, transactionId: '1003', status: 'processed' },
method: 'POST',
times: 1,
},
]);
novuClient = new Novu({
apiKey: 'fakeKey',
serverURL: BACKEND_URL,
httpClient: mockHTTPClient,
});

await novuClient.trigger({ name: 'fake-workflow', to: { subscriberId: '123' }, payload: {} });
expect(mockHTTPClient.getRecordedRequests().length).to.eq(4);
const idempotencyKeys = getIdempotencyKeys(mockHTTPClient);
expect(hasUniqueOnly(idempotencyKeys)).to.be.eq(true);
});

it('should fail after reaching max retries', async () => {
novuClient = new Novu({
apiKey: 'fakeKey',
serverURL: BACKEND_URL,
httpClient: new MockHTTPClient([
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 500,
responseJson: buildErrorDto(TOPICS_PATH, 'Server Exception', 500),
method: 'GET',
times: 4,
},
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 200,
responseJson: [{}, {}],
method: 'GET',
times: 1,
},
]),
});

const { error } = await expectSdkExceptionGeneric(() =>
novuClient.topics.list(
{},
{
retries: {
strategy: 'backoff',
backoff: {
initialInterval: 30,
maxInterval: 60,
exponent: 1,
maxElapsedTime: 150,
},
retryConnectionErrors: true,
},
}
)
);
expect(error?.statusCode).to.be.eq(500);
});

const NON_RECOVERABLE_ERRORS: Array<[number, string]> = [
[400, 'Bad Request'],
[401, 'Unauthorized'],
[403, 'Forbidden'],
[404, 'Not Found'],
[405, 'Method not allowed'],
[413, 'Payload Too Large'],
[414, 'URI Too Long'],
[415, 'Unsupported Media Type'],
];
NON_RECOVERABLE_ERRORS.forEach(([status, message]) => {
it('should not retry on non-recoverable %i error', async () => {
novuClient = new Novu({
apiKey: 'fakeKey',
serverURL: BACKEND_URL,
httpClient: new MockHTTPClient([
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: status,
responseJson: buildErrorDto(TOPICS_PATH, message, status),
method: 'GET',
times: 3,
},
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 200,
responseJson: [{}, {}],
method: 'GET',
times: 1,
},
]),
});

const result = await topicsList(novuClient, {});

expect(result.ok).to.be.eq(false);
});
});

it('should retry on various errors until it reaches successful response', async () => {
const mockClient = new MockHTTPClient([
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 429,
responseJson: buildErrorDto(TOPICS_PATH, 'Too many requests', 429),
method: 'GET',
times: 1,
},
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 408,
responseJson: buildErrorDto(TOPICS_PATH, 'Request Timeout', 408),
method: 'GET',
times: 1,
},
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 504,
responseJson: buildErrorDto(TOPICS_PATH, 'Gateway timeout', 504),
method: 'GET',
times: 1,
},
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 422,
responseJson: buildErrorDto(TOPICS_PATH, 'Unprocessable Content', 422),
method: 'GET',
times: 1,
},
{
baseUrl: BACKEND_URL,
path: TOPICS_PATH,
responseCode: 200,
responseJson: { data: [], page: 1, pageSize: 30, totalCount: 0 } as FilterTopicsResponseDto,
method: 'GET',
times: 1,
},
]);

novuClient = new Novu({
apiKey: 'fakeKey',
serverURL: BACKEND_URL,
httpClient: mockClient,
});

const { error, ok, value } = await topicsList(novuClient, {});
expect(ok).to.be.true;
});
});
const BACKEND_URL = 'http://example.com';
const TOPICS_PATH = '/v1/topics';
const TRIGGER_PATH = '/v1/events/trigger';

const hasAllEqual = (arr: Array<string>) => arr.every((val) => val === arr[0]);
const hasUniqueOnly = (arr: Array<string>) => Array.from(new Set(arr)).length === arr.length;

let novuClient: Novu;

function buildErrorDto(path: string, message: string, status: number): ErrorDto {
return {
path,
timestamp: new Date().toDateString(),
message,
statusCode: status,
};
}

const IDEMPOTENCY_HEADER_KEY = 'idempotency-key';

function getIdempotencyRequestKeys(mockHTTPClient: MockHTTPClient) {
return mockHTTPClient
.getRecordedRequests()
.map((pair) => pair.request.headers.get(IDEMPOTENCY_HEADER_KEY))
.filter((value) => value != null);
}
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"@nestjs/swagger": "7.4.0",
"@nestjs/terminus": "10.2.3",
"@nestjs/throttler": "6.2.1",
"@novu/api": "0.0.1-alpha.150",
"@novu/api": "0.1.0",
"@novu/application-generic": "workspace:*",
"@novu/dal": "workspace:*",
"@novu/framework": "workspace:*",
Expand Down
Loading

0 comments on commit ed6f80a

Please sign in to comment.