mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
fix: Add timeout to community node types request (#18545)
Co-authored-by: Your Name <you@example.com>
This commit is contained in:
@@ -12,6 +12,9 @@ jest.mock('../community-node-types-utils', () => ({
|
|||||||
getCommunityNodeTypes: jest.fn().mockResolvedValue([]),
|
getCommunityNodeTypes: jest.fn().mockResolvedValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockDateNow = jest.spyOn(Date, 'now');
|
||||||
|
const mockMathRandom = jest.spyOn(Math, 'random');
|
||||||
|
|
||||||
describe('CommunityNodeTypesService', () => {
|
describe('CommunityNodeTypesService', () => {
|
||||||
let service: CommunityNodeTypesService;
|
let service: CommunityNodeTypesService;
|
||||||
let configMock: any;
|
let configMock: any;
|
||||||
@@ -30,9 +33,16 @@ describe('CommunityNodeTypesService', () => {
|
|||||||
};
|
};
|
||||||
communityPackagesServiceMock = {};
|
communityPackagesServiceMock = {};
|
||||||
|
|
||||||
|
if (mockDateNow.mockRestore) mockDateNow.mockRestore();
|
||||||
|
if (mockMathRandom.mockRestore) mockMathRandom.mockRestore();
|
||||||
|
|
||||||
service = new CommunityNodeTypesService(loggerMock, configMock, communityPackagesServiceMock);
|
service = new CommunityNodeTypesService(loggerMock, configMock, communityPackagesServiceMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe('fetchNodeTypes', () => {
|
describe('fetchNodeTypes', () => {
|
||||||
it('should use staging environment when ENVIRONMENT=staging', async () => {
|
it('should use staging environment when ENVIRONMENT=staging', async () => {
|
||||||
process.env.ENVIRONMENT = 'staging';
|
process.env.ENVIRONMENT = 'staging';
|
||||||
@@ -59,4 +69,130 @@ describe('CommunityNodeTypesService', () => {
|
|||||||
expect(getCommunityNodeTypes).toHaveBeenCalledWith('staging');
|
expect(getCommunityNodeTypes).toHaveBeenCalledWith('staging');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('updateCommunityNodeTypes', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Date, 'now').mockImplementation(() => 1000000);
|
||||||
|
|
||||||
|
jest.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setTimestampForRetry when nodeTypes is empty array', () => {
|
||||||
|
const setTimestampForRetrySpy = jest.spyOn(service as any, 'setTimestampForRetry');
|
||||||
|
|
||||||
|
(service as any).updateCommunityNodeTypes([]);
|
||||||
|
|
||||||
|
expect(setTimestampForRetrySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setTimestampForRetry when nodeTypes is null', () => {
|
||||||
|
const setTimestampForRetrySpy = jest.spyOn(service as any, 'setTimestampForRetry');
|
||||||
|
|
||||||
|
(service as any).updateCommunityNodeTypes(null);
|
||||||
|
|
||||||
|
expect(setTimestampForRetrySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setTimestampForRetry when nodeTypes is undefined', () => {
|
||||||
|
const setTimestampForRetrySpy = jest.spyOn(service as any, 'setTimestampForRetry');
|
||||||
|
|
||||||
|
(service as any).updateCommunityNodeTypes(undefined);
|
||||||
|
|
||||||
|
expect(setTimestampForRetrySpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early when nodeTypes is empty without updating communityNodeTypes', () => {
|
||||||
|
const resetCommunityNodeTypesSpy = jest.spyOn(service as any, 'resetCommunityNodeTypes');
|
||||||
|
const initialNodeTypes = (service as any).communityNodeTypes;
|
||||||
|
|
||||||
|
(service as any).updateCommunityNodeTypes([]);
|
||||||
|
|
||||||
|
expect(resetCommunityNodeTypesSpy).not.toHaveBeenCalled();
|
||||||
|
expect((service as any).communityNodeTypes).toBe(initialNodeTypes);
|
||||||
|
|
||||||
|
expect((service as any).lastUpdateTimestamp).not.toBe(1000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should process nodeTypes normally when array has content', () => {
|
||||||
|
const mockNodeTypes = [
|
||||||
|
{ name: 'test-node-1', version: '1.0.0' },
|
||||||
|
{ name: 'test-node-2', version: '1.1.0' },
|
||||||
|
];
|
||||||
|
const resetCommunityNodeTypesSpy = jest.spyOn(service as any, 'resetCommunityNodeTypes');
|
||||||
|
|
||||||
|
(service as any).updateCommunityNodeTypes(mockNodeTypes);
|
||||||
|
|
||||||
|
expect(resetCommunityNodeTypesSpy).toHaveBeenCalledTimes(1);
|
||||||
|
expect((service as any).communityNodeTypes.size).toBe(2);
|
||||||
|
expect((service as any).communityNodeTypes.get('test-node-1')).toEqual(mockNodeTypes[0]);
|
||||||
|
expect((service as any).communityNodeTypes.get('test-node-2')).toEqual(mockNodeTypes[1]);
|
||||||
|
expect((service as any).lastUpdateTimestamp).toBe(1000000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setTimestampForRetry', () => {
|
||||||
|
const UPDATE_INTERVAL = 8 * 60 * 60 * 1000;
|
||||||
|
const RETRY_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Date, 'now').mockImplementation(() => 1000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set timestamp with jitter for retry', () => {
|
||||||
|
jest.spyOn(Math, 'random').mockImplementation(() => 0.5);
|
||||||
|
|
||||||
|
(service as any).setTimestampForRetry();
|
||||||
|
|
||||||
|
const expectedTimestamp = 1000000 - (UPDATE_INTERVAL - RETRY_INTERVAL + 0);
|
||||||
|
expect((service as any).lastUpdateTimestamp).toBe(expectedTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set timestamp with negative jitter', () => {
|
||||||
|
jest.spyOn(Math, 'random').mockImplementation(() => 0);
|
||||||
|
|
||||||
|
(service as any).setTimestampForRetry();
|
||||||
|
|
||||||
|
const expectedJitter = -120000;
|
||||||
|
const expectedTimestamp = 1000000 - (UPDATE_INTERVAL - RETRY_INTERVAL + expectedJitter);
|
||||||
|
expect((service as any).lastUpdateTimestamp).toBe(expectedTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set timestamp with positive jitter', () => {
|
||||||
|
jest.spyOn(Math, 'random').mockImplementation(() => 1);
|
||||||
|
|
||||||
|
(service as any).setTimestampForRetry();
|
||||||
|
|
||||||
|
const expectedJitter = 120000;
|
||||||
|
const expectedTimestamp = 1000000 - (UPDATE_INTERVAL - RETRY_INTERVAL + expectedJitter);
|
||||||
|
expect((service as any).lastUpdateTimestamp).toBe(expectedTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should calculate jitter within expected range', () => {
|
||||||
|
const testCases = [0, 0.25, 0.5, 0.75, 1];
|
||||||
|
|
||||||
|
testCases.forEach((randomValue, index) => {
|
||||||
|
const testTimestamp = 2000000 + index * 1000;
|
||||||
|
jest.spyOn(Math, 'random').mockImplementation(() => randomValue);
|
||||||
|
jest.spyOn(Date, 'now').mockImplementation(() => testTimestamp);
|
||||||
|
|
||||||
|
(service as any).setTimestampForRetry();
|
||||||
|
|
||||||
|
const expectedJitter = Math.floor(randomValue * 4 * 60 * 1000) - 2 * 60 * 1000;
|
||||||
|
const expectedTimestamp =
|
||||||
|
testTimestamp - (UPDATE_INTERVAL - RETRY_INTERVAL + expectedJitter);
|
||||||
|
|
||||||
|
expect((service as any).lastUpdateTimestamp).toBe(expectedTimestamp);
|
||||||
|
expect(expectedJitter).toBeGreaterThanOrEqual(-120000);
|
||||||
|
expect(expectedJitter).toBeLessThanOrEqual(120000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import nock from 'nock';
|
import nock from 'nock';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
import { paginatedRequest } from '../strapi-utils';
|
import { paginatedRequest } from '../strapi-utils';
|
||||||
|
|
||||||
@@ -124,5 +125,73 @@ describe('Strapi utils', () => {
|
|||||||
|
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should apply the correct timeout value to axios requests', async () => {
|
||||||
|
const axiosGetSpy = jest.spyOn(axios, 'get');
|
||||||
|
|
||||||
|
nock('https://strapi.test')
|
||||||
|
.get('/api/nodes')
|
||||||
|
.query(true)
|
||||||
|
.reply(200, {
|
||||||
|
data: [],
|
||||||
|
meta: {
|
||||||
|
pagination: {
|
||||||
|
page: 1,
|
||||||
|
pageSize: 25,
|
||||||
|
pageCount: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await paginatedRequest(baseUrl);
|
||||||
|
|
||||||
|
expect(axiosGetSpy).toHaveBeenCalledWith(
|
||||||
|
baseUrl,
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: 3000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
axiosGetSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle timeout errors and return empty array', async () => {
|
||||||
|
const timeoutError = new Error('timeout of 3000ms exceeded');
|
||||||
|
timeoutError.name = 'AxiosError';
|
||||||
|
(timeoutError as any).code = 'ECONNABORTED';
|
||||||
|
|
||||||
|
nock('https://strapi.test')
|
||||||
|
.get('/api/nodes')
|
||||||
|
.query(true)
|
||||||
|
.delayConnection(4000) // Delay longer than timeout
|
||||||
|
.reply(200, { data: [] });
|
||||||
|
|
||||||
|
const result = await paginatedRequest(baseUrl);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle network timeout and continue gracefully', async () => {
|
||||||
|
// Mock axios to simulate timeout
|
||||||
|
const axiosGetSpy = jest.spyOn(axios, 'get').mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error('timeout of 3000ms exceeded'), {
|
||||||
|
code: 'ECONNABORTED',
|
||||||
|
name: 'AxiosError',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await paginatedRequest(baseUrl);
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
expect(axiosGetSpy).toHaveBeenCalledWith(
|
||||||
|
baseUrl,
|
||||||
|
expect.objectContaining({
|
||||||
|
timeout: 3000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
axiosGetSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CommunityPackagesConfig } from './community-packages.config';
|
|||||||
import { CommunityPackagesService } from './community-packages.service';
|
import { CommunityPackagesService } from './community-packages.service';
|
||||||
|
|
||||||
const UPDATE_INTERVAL = 8 * 60 * 60 * 1000;
|
const UPDATE_INTERVAL = 8 * 60 * 60 * 1000;
|
||||||
|
const RETRY_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
@Service()
|
@Service()
|
||||||
export class CommunityNodeTypesService {
|
export class CommunityNodeTypesService {
|
||||||
@@ -45,7 +46,13 @@ export class CommunityNodeTypesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateCommunityNodeTypes(nodeTypes: StrapiCommunityNodeType[]) {
|
private updateCommunityNodeTypes(nodeTypes: StrapiCommunityNodeType[]) {
|
||||||
if (!nodeTypes?.length) return;
|
if (!nodeTypes?.length) {
|
||||||
|
// When we get empty data, don't wait the full UPDATE_INTERVAL to try again.
|
||||||
|
// Instead, set the timestamp to retry after RETRY_INTERVAL with some
|
||||||
|
// random jitter to avoid all instances retrying at once
|
||||||
|
this.setTimestampForRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.resetCommunityNodeTypes();
|
this.resetCommunityNodeTypes();
|
||||||
|
|
||||||
@@ -63,6 +70,11 @@ export class CommunityNodeTypesService {
|
|||||||
return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL;
|
return Date.now() - this.lastUpdateTimestamp > UPDATE_INTERVAL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setTimestampForRetry() {
|
||||||
|
const jitter = Math.floor(Math.random() * 4 * 60 * 1000) - 2 * 60 * 1000;
|
||||||
|
this.lastUpdateTimestamp = Date.now() - (UPDATE_INTERVAL - RETRY_INTERVAL + jitter);
|
||||||
|
}
|
||||||
|
|
||||||
private async createIsInstalled() {
|
private async createIsInstalled() {
|
||||||
const installedPackages = (await this.communityPackagesService.getAllInstalledPackages()) ?? [];
|
const installedPackages = (await this.communityPackagesService.getAllInstalledPackages()) ?? [];
|
||||||
const installedPackageNames = new Set(installedPackages.map((p) => p.packageName));
|
const installedPackageNames = new Set(installedPackages.map((p) => p.packageName));
|
||||||
@@ -71,7 +83,7 @@ export class CommunityNodeTypesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCommunityNodeTypes(): Promise<CommunityNodeType[]> {
|
async getCommunityNodeTypes(): Promise<CommunityNodeType[]> {
|
||||||
if (this.updateRequired() || !this.communityNodeTypes.size) {
|
if (this.updateRequired()) {
|
||||||
await this.fetchNodeTypes();
|
await this.fetchNodeTypes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface Pagination {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REQUEST_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
export async function paginatedRequest<T>(url: string): Promise<T[]> {
|
export async function paginatedRequest<T>(url: string): Promise<T[]> {
|
||||||
let returnData: T[] = [];
|
let returnData: T[] = [];
|
||||||
let responseData: T[] | undefined = [];
|
let responseData: T[] | undefined = [];
|
||||||
@@ -41,6 +43,7 @@ export async function paginatedRequest<T>(url: string): Promise<T[]> {
|
|||||||
response = await axios.get<ResponseData<T>>(url, {
|
response = await axios.get<ResponseData<T>>(url, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
params,
|
params,
|
||||||
|
timeout: REQUEST_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Container.get(ErrorReporter).error(error, {
|
Container.get(ErrorReporter).error(error, {
|
||||||
|
|||||||
Reference in New Issue
Block a user