chore: init load tests (#7635)

* init load tests

* add machine pat

* setup app

* add introspect

* use xk6-modules repo

* logging

* add teardown

* add manipulate user

* add manipulate user

* remove logs

* convert tests to ts

* add readme

* zitadel

* review comments
This commit is contained in:
Silvan
2024-04-18 11:21:07 +02:00
committed by GitHub
parent dbb824a73f
commit d337668599
22 changed files with 5612 additions and 0 deletions

70
load-test/src/app.ts Normal file
View File

@@ -0,0 +1,70 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http from 'k6/http';
import url from './url';
import { check } from 'k6';
export type API = {
appId: string;
};
const addAPITrend = new Trend('app_add_app_duration', true);
export function createAPI(name: string, projectId: string, org: Org, accessToken: string): Promise<API> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/projects/${projectId}/apps/api`),
JSON.stringify({
name: name,
authMethodType: 'API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT',
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add api status ok': (r) => r.status === 200,
}) || reject(`unable to add api project: ${projectId} status: ${res.status} body: ${res.body}`);
resolve(res.json() as API);
addAPITrend.add(res.timings.duration);
});
});
}
export type AppKey = {
keyDetails: string;
};
const addAppKeyTrend = new Trend('app_add_app_key_duration', true);
export function createAppKey(appId: string, projectId: string, org: Org, accessToken: string): Promise<AppKey> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url(`/management/v1/projects/${projectId}/apps/${appId}/keys`),
JSON.stringify({
type: 'KEY_TYPE_JSON',
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add app key status ok': (r) => r.status === 200,
}) || reject(`unable to add app key project: ${projectId} app: ${appId} status: ${res.status} body: ${res.body}`);
resolve(res.json() as AppKey);
addAppKeyTrend.add(res.timings.duration);
});
});
}

67
load-test/src/config.ts Normal file
View File

@@ -0,0 +1,67 @@
// @ts-ignore Import module
import { randomString } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js';
import crypto from 'k6/crypto';
import http from 'k6/http';
import execution from 'k6/execution';
import { Stage } from 'k6/options';
import url from './url';
export const Config = {
host: __ENV.ZITADEL_HOST || 'http://localhost:8080',
orgId: '',
codeVerifier: __ENV.CODE_VERIFIER || randomString(10),
admin: {
loginName: __ENV.ADMIN_LOGIN_NAME || 'zitadel-admin@zitadel.localhost',
password: __ENV.ADMIN_PASSWORD || 'Password1!',
},
};
const client = {
response_type: 'code',
scope: 'openid email profile urn:zitadel:iam:org:project:id:zitadel:aud',
prompt: 'login',
code_challenge_method: 'S256',
code_challenge: crypto.sha256(Config.codeVerifier, 'base64rawurl'),
client_id: __ENV.CLIENT_ID || '',
redirect_uri: url('/ui/console/auth/callback'),
};
export function Client() {
if (client.client_id) {
return client;
}
const env = http.get(url('/ui/console/assets/environment.json'));
client.client_id = env.json('clientid') ? env.json('clientid')?.toString()! : '';
return client;
}
let maxVUs: number;
export function MaxVUs() {
if (maxVUs != undefined) {
return maxVUs;
}
let max: number = execution.test.options.stages
? execution.test.options.stages
.map((value: Stage): number => value.target)
.reduce((acc: number, value: number): number => {
return acc <= value ? acc : value;
})
: 1;
if (execution.test.options.scenarios) {
new Map(Object.entries(execution.test.options.scenarios)).forEach((value) => {
if ('vus' in value) {
max = value.vus && max < value.vus ? value.vus : max;
} else if ('maxVUs' in value) {
max = value.maxVUs && max < value.maxVUs ? value.maxVUs : max;
}
});
}
maxVUs = max;
return maxVUs;
}

113
load-test/src/login_ui.ts Normal file
View File

@@ -0,0 +1,113 @@
import { JSONObject, check, fail } from 'k6';
import http, { Response } from 'k6/http';
// @ts-ignore Import module
import { URL } from 'https://jslib.k6.io/url/1.0.0/index.js';
import { Trend } from 'k6/metrics';
import { Config, Client } from './config';
import url from './url';
import { User } from './user';
import { Tokens } from './oidc';
export function loginByUsernamePassword(user: User) {
check(user, {
'user defined': (u) => u !== undefined || fail(`user is undefined`),
});
const loginUI = initLogin();
const loginNameResponse = enterLoginName(loginUI, user);
const passwordResponse = enterPassword(loginNameResponse, user);
return token(new URL(passwordResponse.url).searchParams.get('code'));
}
const initLoginTrend = new Trend('login_ui_init_login_duration', true);
function initLogin(): Response {
const response = http.get(url('/oauth/v2/authorize', { searchParams: Client() }));
check(response, {
'authorize status ok': (r) => r.status == 200 || fail(`init login failed: ${r}`),
});
initLoginTrend.add(response.timings.duration);
return response;
}
const enterLoginNameTrend = new Trend('login_ui_enter_login_name_duration', true);
function enterLoginName(page: Response, user: User): Response {
const response = page.submitForm({
formSelector: 'form',
fields: {
loginName: user.loginName,
},
});
check(response, {
'login name status ok': (r) => (r && r.status == 200) || fail('enter login name failed'),
'login shows password page': (r) => r && r.body !== null && r.body.toString().includes('password'),
// 'login has no error': (r) => r && r.body != null && r.body.toString().includes('error') || fail(`error in enter login name ${r.body}`)
});
enterLoginNameTrend.add(response.timings.duration);
return response;
}
const enterPasswordTrend = new Trend('login_ui_enter_password_duration', true);
function enterPassword(page: Response, user: User): Response {
let response = page.submitForm({
formSelector: 'form',
fields: {
password: user.password,
},
});
enterPasswordTrend.add(response.timings.duration);
// skip 2fa init
if (response.url.endsWith('/password')) {
response = response.submitForm({
formSelector: 'form',
submitSelector: '[name="skip"]',
});
}
check(response, {
'password status ok': (r) => r.status == 200 || fail('enter password failed'),
'password callback': (r) =>
r.url.startsWith(url('/ui/console/auth/callback?code=')) || fail(`wrong password callback: ${r.url}`),
});
return response;
}
const tokenTrend = new Trend('login_ui_token_duration', true);
function token(code = '') {
check(code, {
'code set': (c) => (c !== undefined && c !== null) || fail('code was not set'),
});
const response = http.post(
url('/oauth/v2/token'),
{
grant_type: 'authorization_code',
code: code,
redirect_uri: Client().redirect_uri,
code_verifier: Config.codeVerifier,
client_id: Client().client_id,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
tokenTrend.add(response.timings.duration);
check(response, {
'token status ok': (r) => r.status == 200 || fail(`invalid token response status: ${r.status} body: ${r.body}`),
});
const token = new Tokens(response.json() as JSONObject);
check(token, {
'access token created': (t) => t.accessToken !== undefined,
'id token created': (t) => t.idToken !== undefined,
'info created': (t) => t.info !== undefined,
});
return token;
}

74
load-test/src/oidc.ts Normal file
View File

@@ -0,0 +1,74 @@
import { JSONObject, check, fail } from 'k6';
import encoding from 'k6/encoding';
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
export class Tokens {
idToken?: string;
accessToken?: string;
info?: any;
constructor(res: JSONObject) {
this.idToken = res.id_token ? res.id_token!.toString() : undefined;
this.accessToken = res.access_token ? res.access_token!.toString() : undefined;
this.info = this.idToken
? JSON.parse(encoding.b64decode(this.idToken?.split('.')[1].toString(), 'rawstd', 's'))
: undefined;
}
}
let oidcConfig: any | undefined;
function configuration() {
if (oidcConfig !== undefined) {
return oidcConfig;
}
const res = http.get(url('/.well-known/openid-configuration'));
check(res, {
'openid configuration': (r) => r.status == 200 || fail('unable to load openid configuration'),
});
oidcConfig = res.json();
return oidcConfig;
}
const userinfoTrend = new Trend('oidc_user_info_duration', true);
export function userinfo(token: string) {
const userinfo = http.get(configuration().userinfo_endpoint, {
headers: {
authorization: 'Bearer ' + token,
'Content-Type': 'application/json',
},
});
check(userinfo, {
'userinfo status ok': (r) => r.status === 200,
});
userinfoTrend.add(userinfo.timings.duration);
}
const introspectTrend = new Trend('oidc_introspect_duration', true);
export function introspect(jwt: string, token: string) {
const res = http.post(
configuration().introspection_endpoint,
{
client_assertion: jwt,
token: token,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
alg: 'RS256',
},
},
);
check(res, {
'introspect status ok': (r) => r.status === 200,
});
introspectTrend.add(res.timings.duration);
}

56
load-test/src/org.ts Normal file
View File

@@ -0,0 +1,56 @@
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { Config } from './config';
import { check } from 'k6';
export type Org = {
organizationId: string;
};
const createOrgTrend = new Trend('org_create_org_duration', true);
export function createOrg(accessToken: string): Promise<Org> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/v2beta/organizations'),
JSON.stringify({
name: `load-test-${new Date(Date.now()).toISOString()}`,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': Config.orgId,
},
},
);
response.then((res) => {
check(res, {
'org created': (r) => {
return r !== undefined && r.status === 201;
},
}) || reject(`unable to create org status: ${res.status} || body: ${res.body}`);
createOrgTrend.add(res.timings.duration);
resolve(res.json() as Org);
});
});
}
export function removeOrg(org: Org, accessToken: string) {
const response = http.del(url('/management/v1/orgs/me'), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'x-zitadel-orgid': org.organizationId,
},
});
check(response, {
'org removed': (r) => r.status === 200,
}) || console.log(`status: ${response.status} || body: ${response.body}|| org: ${JSON.stringify(org)}`);
return response.json();
}

37
load-test/src/project.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http from 'k6/http';
import url from './url';
import { check } from 'k6';
export type Project = {
id: string;
};
const addProjectTrend = new Trend('project_add_project_duration', true);
export function createProject(name: string, org: Org, accessToken: string): Promise<Project> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/management/v1/projects'),
JSON.stringify({
name: name,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response.then((res) => {
check(res, {
'add project status ok': (r) => r.status === 200,
}) || reject(`unable to add project status: ${res.status} body: ${res.body}`);
addProjectTrend.add(res.timings.duration);
resolve(res.json() as Project);
});
});
}

18
load-test/src/url.ts Normal file
View File

@@ -0,0 +1,18 @@
import { options } from 'k6/http';
import { Config } from './config';
export type options = {
searchParams?: { [name: string]: string };
};
export default function url(path: string, options: options = {}) {
let url = new URL(Config.host + path);
if (options.searchParams) {
Object.entries(options.searchParams).forEach(([key, value]) => {
url.searchParams.append(key, value);
});
}
return url.toString();
}

View File

@@ -0,0 +1,37 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { User, createHuman } from '../user';
import { userinfo } from '../oidc';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../config';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, users: humans, org };
}
const humanPasswordLoginTrend = new Trend('human_password_login_duration', true);
export default function (data: any) {
const start = new Date();
const token = loginByUsernamePassword(data.users[__VU - 1]);
userinfo(token.accessToken!);
humanPasswordLoginTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}

View File

@@ -0,0 +1,54 @@
import { loginByUsernamePassword } from '../login_ui';
import { createAPI, createAppKey } from '../app';
import { createProject } from '../project';
import { createOrg, removeOrg } from '../org';
import { introspect } from '../oidc';
import { Config, MaxVUs } from '../config';
import { b64decode } from 'k6/encoding';
// @ts-ignore Import module
import zitadel from 'k6/x/zitadel';
import { User } from '../user';
export async function setup() {
const adminTokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(adminTokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const projectPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createProject(`project-${i}`, org, adminTokens.accessToken!);
});
const projects = await Promise.all(projectPromises);
console.log(`setup: ${projects.length} projects created`);
const apis = await Promise.all(
projects.map((project, i) => {
return createAPI(`api-${i}`, project.id, org, adminTokens.accessToken!);
}),
);
console.info(`setup: ${apis.length} apis created`);
const keys = await Promise.all(
apis.map((api, i) => {
return createAppKey(api.appId, projects[i].id, org, adminTokens.accessToken!);
}),
);
console.info(`setup: ${keys.length} keys created`);
const tokens = keys.map((key) => {
return zitadel.jwtFromKey(b64decode(key.keyDetails, 'url', 's'), Config.host);
});
console.info(`setup: ${tokens.length} tokens generated`);
return { adminTokens, tokens, org };
}
export default function (data: any) {
introspect(data.tokens[__VU - 1], data.adminTokens.accessToken);
}
export function teardown(data: any) {
removeOrg(data.org, data.adminTokens.accessToken);
console.info('teardown: org removed');
}

View File

@@ -0,0 +1,46 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createMachine, addMachinePat, User } from '../user';
import { userinfo } from '../oidc';
import { Config, MaxVUs } from '../config';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
let machines = (
await Promise.all(
Array.from({ length: MaxVUs() }, (_, i) => {
return createMachine(`zitachine-${i}`, org, tokens.accessToken!);
}),
)
).map((machine) => {
return { userId: machine.userId, loginName: machine.loginNames[0] };
});
console.info(`setup: ${machines.length} machines created`);
let pats = (
await Promise.all(
machines.map((machine) => {
return addMachinePat(machine.userId, org, tokens.accessToken!);
}),
)
).map((pat, i) => {
return { userId: machines[i].userId, loginName: machines[i].loginName, pat: pat.token };
});
console.info(`setup: Pats added`);
return { tokens, machines: pats, org };
}
export default function (data: any) {
userinfo(data.machines[__VU - 1].pat);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}

View File

@@ -0,0 +1,47 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { createHuman, updateHuman, lockUser, deleteUser, User } from '../user';
import { Config } from '../config';
import { check } from 'k6';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
return { tokens, org };
}
export default async function (data: any) {
const human = await createHuman(`vu-${__VU}`, data.org, data.tokens.accessToken);
const updateRes = await updateHuman(
{
profile: {
nickName: `${new Date(Date.now()).toISOString()}`,
},
},
human.userId,
data.org,
data.tokens.accessToken,
);
check(updateRes, {
'update user is status ok': (r) => r.status >= 200 && r.status < 300,
});
const lockRes = await lockUser(human.userId, data.org, data.tokens.accessToken);
check(lockRes, {
'lock user is status ok': (r) => r.status >= 200 && r.status < 300,
});
const deleteRes = await deleteUser(human.userId, data.org, data.tokens.accessToken);
check(deleteRes, {
'delete user is status ok': (r) => r.status >= 200 && r.status < 300,
});
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}

View File

@@ -0,0 +1,27 @@
import { loginByUsernamePassword } from '../login_ui';
import { userinfo } from '../oidc';
import { Config } from '../config';
import { User, createHuman } from '../user';
import { createOrg, removeOrg } from '../org';
export async function setup() {
const adminTokens = loginByUsernamePassword(Config.admin as User);
console.info('setup: admin signed in');
const org = await createOrg(adminTokens.accessToken!);
console.info(`setup: org (${org.organizationId}) created`);
const user = await createHuman('gigi', org, adminTokens.accessToken!);
console.info(`setup: user (${user.userId}) created`);
return { org, tokens: loginByUsernamePassword({ loginName: user.loginNames[0], password: 'Password1!' } as User) };
}
export default function (data: any) {
userinfo(data.tokens.accessToken);
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
console.info('teardown: org removed');
}

222
load-test/src/user.ts Normal file
View File

@@ -0,0 +1,222 @@
import { Trend } from 'k6/metrics';
import { Org } from './org';
import http, { RefinedResponse } from 'k6/http';
import url from './url';
import { check } from 'k6';
export type User = {
userId: string;
loginName: string;
password: string;
};
export interface Human extends User {
loginNames: string[];
}
const createHumanTrend = new Trend('user_create_human_duration', true);
export function createHuman(username: string, org: Org, accessToken: string): Promise<Human> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/v2beta/users/human'),
JSON.stringify({
username: username,
organization: {
orgId: org.organizationId,
},
profile: {
givenName: 'Gigi',
familyName: 'Zitizen',
},
email: {
email: `zitizen-@caos.ch`,
isVerified: true,
},
password: {
password: 'Password1!',
changeRequired: false,
},
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response
.then((res) => {
check(res, {
'create user is status ok': (r) => r.status === 201,
}) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`);
createHumanTrend.add(res.timings.duration);
const user = http.get(url(`/v2beta/users/${res.json('userId')!}`), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
resolve(user.json('user')! as unknown as Human);
})
.catch((reason) => {
reject(reason);
});
});
}
const updateHumanTrend = new Trend('update_human_duration', true);
export function updateHuman(
payload: any = {},
userId: string,
org: Org,
accessToken: string,
): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('PUT', url(`/v2beta/users/${userId}`), JSON.stringify(payload), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response
.then((res) => {
check(res, {
'update user is status ok': (r) => r.status === 201,
});
updateHumanTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
export interface Machine extends User {
loginNames: string[];
}
const createMachineTrend = new Trend('user_create_machine_duration', true);
export function createMachine(username: string, org: Org, accessToken: string): Promise<Machine> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/management/v1/users/machine'),
JSON.stringify({
userName: username,
name: username,
// bearer
access_token_type: 0,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
},
);
response
.then((res) => {
check(res, {
'create user is status ok': (r) => r.status === 200,
}) || reject(`unable to create user(username: ${username}) status: ${res.status} body: ${res.body}`);
createMachineTrend.add(res.timings.duration);
const user = http.get(url(`/v2beta/users/${res.json('userId')!}`), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
resolve(user.json('user')! as unknown as Machine);
})
.catch((reason) => {
reject(reason);
});
});
}
export type MachinePat = {
token: string;
};
const addMachinePatTrend = new Trend('user_add_machine_pat_duration', true);
export function addMachinePat(userId: string, org: Org, accessToken: string): Promise<MachinePat> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/management/v1/users/${userId}/pats`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response.then((res) => {
check(res, {
'add pat status ok': (r) => r.status === 200,
}) || reject(`unable to add pat (user id: ${userId}) status: ${res.status} body: ${res.body}`);
addMachinePatTrend.add(res.timings.duration);
resolve(res.json()! as MachinePat);
});
});
}
const lockUserTrend = new Trend('lock_user_duration', true);
export function lockUser(userId: string, org: Org, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('POST', url(`/v2beta/users/${userId}/lock`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response
.then((res) => {
check(res, {
'update user is status ok': (r) => r.status === 201,
});
lockUserTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}
const deleteUserTrend = new Trend('delete_user_duration', true);
export function deleteUser(userId: string, org: Org, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest('DELETE', url(`/v2beta/users/${userId}`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
response
.then((res) => {
check(res, {
'update user is status ok': (r) => r.status === 201,
});
deleteUserTrend.add(res.timings.duration);
resolve(res);
})
.catch((reason) => {
reject(reason);
});
});
}