mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	fix: concurrency issue when running multiple requests (#3442)
## About the changes Fix issue when running multiple calls to the /frontend endpoint concurrently, which ends up creating many instances of unleash SDK client.
This commit is contained in:
		
							parent
							
								
									d18b04de72
								
							
						
					
					
						commit
						2bfbe3cd79
					
				@ -200,7 +200,7 @@ export class ApiTokenController extends Controller {
 | 
			
		||||
        const { token } = req.params;
 | 
			
		||||
 | 
			
		||||
        await this.apiTokenService.delete(token, extractUsername(req));
 | 
			
		||||
        this.proxyService.deleteClientForProxyToken(token);
 | 
			
		||||
        await this.proxyService.deleteClientForProxyToken(token);
 | 
			
		||||
        res.status(200).end();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -187,7 +187,7 @@ export class ProjectApiTokenController extends Controller {
 | 
			
		||||
                    storedToken.project[0] === projectId))
 | 
			
		||||
        ) {
 | 
			
		||||
            await this.apiTokenService.delete(token, extractUsername(req));
 | 
			
		||||
            this.proxyService.deleteClientForProxyToken(token);
 | 
			
		||||
            await this.proxyService.deleteClientForProxyToken(token);
 | 
			
		||||
            res.status(200).end();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -42,7 +42,13 @@ export class ProxyService {
 | 
			
		||||
 | 
			
		||||
    private readonly services: Services;
 | 
			
		||||
 | 
			
		||||
    private readonly clients: Map<ApiUser['secret'], Unleash> = new Map();
 | 
			
		||||
    /**
 | 
			
		||||
     * This is intentionally a Promise becasue we want to be able to await
 | 
			
		||||
     * until the client (which might be being created by a different request) is ready
 | 
			
		||||
     * Check this test that fails if we don't use a Promise: src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts
 | 
			
		||||
     */
 | 
			
		||||
    private readonly clients: Map<ApiUser['secret'], Promise<Unleash>> =
 | 
			
		||||
        new Map();
 | 
			
		||||
 | 
			
		||||
    private cachedFrontendSettings?: FrontendSettings;
 | 
			
		||||
 | 
			
		||||
@ -99,14 +105,13 @@ export class ProxyService {
 | 
			
		||||
    private async clientForProxyToken(token: ApiUser): Promise<Unleash> {
 | 
			
		||||
        ProxyService.assertExpectedTokenType(token);
 | 
			
		||||
 | 
			
		||||
        if (!this.clients.has(token.secret)) {
 | 
			
		||||
            this.clients.set(
 | 
			
		||||
                token.secret,
 | 
			
		||||
                await this.createClientForProxyToken(token),
 | 
			
		||||
            );
 | 
			
		||||
        let client = this.clients.get(token.secret);
 | 
			
		||||
        if (!client) {
 | 
			
		||||
            client = this.createClientForProxyToken(token);
 | 
			
		||||
            this.clients.set(token.secret, client);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return this.clients.get(token.secret);
 | 
			
		||||
        return client;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private async createClientForProxyToken(token: ApiUser): Promise<Unleash> {
 | 
			
		||||
@ -134,13 +139,17 @@ export class ProxyService {
 | 
			
		||||
        return client;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    deleteClientForProxyToken(secret: string): void {
 | 
			
		||||
        this.clients.get(secret)?.destroy();
 | 
			
		||||
        this.clients.delete(secret);
 | 
			
		||||
    async deleteClientForProxyToken(secret: string): Promise<void> {
 | 
			
		||||
        let clientPromise = this.clients.get(secret);
 | 
			
		||||
        if (clientPromise) {
 | 
			
		||||
            const client = await clientPromise;
 | 
			
		||||
            client.destroy();
 | 
			
		||||
            this.clients.delete(secret);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    stopAll(): void {
 | 
			
		||||
        this.clients.forEach((client) => client.destroy());
 | 
			
		||||
        this.clients.forEach((promise) => promise.then((c) => c.destroy()));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private static assertExpectedTokenType({ type }: ApiUser) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										64
									
								
								src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/test/e2e/api/proxy/proxy.concurrency.e2e.test.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,64 @@
 | 
			
		||||
import { IUnleashTest, setupAppWithAuth } from '../../helpers/test-helper';
 | 
			
		||||
import dbInit, { ITestDb } from '../../helpers/database-init';
 | 
			
		||||
import getLogger from '../../../fixtures/no-logger';
 | 
			
		||||
import { randomId } from '../../../../lib/util';
 | 
			
		||||
import { ApiTokenType } from '../../../../lib/types/models/api-token';
 | 
			
		||||
 | 
			
		||||
let app: IUnleashTest;
 | 
			
		||||
let db: ITestDb;
 | 
			
		||||
let appErrorLogs: string[] = [];
 | 
			
		||||
 | 
			
		||||
beforeAll(async () => {
 | 
			
		||||
    db = await dbInit(`proxy_concurrency`, getLogger);
 | 
			
		||||
    const baseLogger = getLogger();
 | 
			
		||||
    const appLogger = {
 | 
			
		||||
        ...baseLogger,
 | 
			
		||||
        error: (msg: string, ...args: any[]) => {
 | 
			
		||||
            appErrorLogs.push(msg);
 | 
			
		||||
            baseLogger.error(msg, ...args);
 | 
			
		||||
        },
 | 
			
		||||
    };
 | 
			
		||||
    app = await setupAppWithAuth(db.stores, {
 | 
			
		||||
        frontendApiOrigins: ['https://example.com'],
 | 
			
		||||
        getLogger: () => appLogger,
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
afterEach(() => {
 | 
			
		||||
    app.services.proxyService.stopAll();
 | 
			
		||||
    jest.clearAllMocks();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
afterAll(async () => {
 | 
			
		||||
    await app.destroy();
 | 
			
		||||
    await db.destroy();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
beforeEach(async () => {
 | 
			
		||||
    appErrorLogs = [];
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * This test needs to run on a new instance of the application and a clean DB
 | 
			
		||||
 * which is why it should be the only test of this file
 | 
			
		||||
 */
 | 
			
		||||
test('multiple parallel calls to api/frontend should not create multiple instances', async () => {
 | 
			
		||||
    const frontendTokenDefault =
 | 
			
		||||
        await app.services.apiTokenService.createApiTokenWithProjects({
 | 
			
		||||
            type: ApiTokenType.FRONTEND,
 | 
			
		||||
            projects: ['default'],
 | 
			
		||||
            environment: 'default',
 | 
			
		||||
            username: `test-token-${randomId()}`,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
    await Promise.all(
 | 
			
		||||
        Array.from(Array(15).keys()).map(() =>
 | 
			
		||||
            app.request
 | 
			
		||||
                .get('/api/frontend')
 | 
			
		||||
                .set('Authorization', frontendTokenDefault.secret)
 | 
			
		||||
                .expect('Content-Type', /json/)
 | 
			
		||||
                .expect(200),
 | 
			
		||||
        ),
 | 
			
		||||
    );
 | 
			
		||||
    expect(appErrorLogs).toHaveLength(0);
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user