From 2de995d5831c7653c4eba4e6669e223deb36098f Mon Sep 17 00:00:00 2001 From: Ajima Chukwuemeka <32770340+ajimae@users.noreply.github.com> Date: Wed, 17 Nov 2021 13:27:31 +0100 Subject: [PATCH] feat(project-suspension): add project suspension with retries (#1728) --- .../sdk-middleware-auth/src/base-auth-flow.js | 244 +++++++++++------- .../test/base-auth-flow.spec.js | 135 ++++++++++ types/sdk.js | 9 +- 3 files changed, 296 insertions(+), 92 deletions(-) diff --git a/packages/sdk-middleware-auth/src/base-auth-flow.js b/packages/sdk-middleware-auth/src/base-auth-flow.js index 029b7555f..0e19434c1 100644 --- a/packages/sdk-middleware-auth/src/base-auth-flow.js +++ b/packages/sdk-middleware-auth/src/base-auth-flow.js @@ -34,6 +34,23 @@ function calculateExpirationTime(expiresIn: number): number { ) } +function calcDelayDuration( + retryCount: number, + retryDelay: number = 60000, // 60 seconds retry delay + maxRetries: number, + backoff: boolean = true, + maxDelay: number = Infinity +): number { + if (backoff) + return retryCount !== 0 // do not increase if it's the first retry + ? Math.min( + Math.round((Math.random() + 1) * retryDelay * 2 ** retryCount), + maxDelay + ) + : retryDelay + return retryDelay +} + function executeRequest({ fetcher, url, @@ -47,6 +64,12 @@ function executeRequest({ tokenCacheKey, timeout, getAbortController, + retryConfig: { + retryDelay = 300, // 60 seconds retry delay + maxRetries = 10, + backoff = true, // encourage exponential backoff + maxDelay = Infinity, + } = {}, }: executeRequestOptions) { // if timeout is configured and no instance of AbortController is passed then throw if ( @@ -65,112 +88,148 @@ function executeRequest({ 'The passed value for timeout is not a number, please provide a timeout of type number.' ) - let signal - let abortController: any - if (timeout || getAbortController) - abortController = - (getAbortController ? getAbortController() : null) || - new AbortController() - if (abortController) { - signal = abortController.signal - } + let retryCount = 0 + function executeFetch() { + let signal + let abortController: any + if (timeout || getAbortController) + abortController = + (getAbortController ? getAbortController() : null) || + new AbortController() + if (abortController) { + signal = abortController.signal + } - let timer - if (timeout) - timer = setTimeout(() => { - abortController.abort() - }, timeout) - fetcher(url, { - method: 'POST', - headers: { - Authorization: `Basic ${basicAuth}`, - 'Content-Length': Buffer.byteLength(body).toString(), - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body, - signal, - }) - .then((res: Response): Promise<*> => { - if (res.ok) - return res - .json() - .then( - ({ - access_token: token, - expires_in: expiresIn, - refresh_token: refreshToken, - }: Object) => { - const expirationTime = calculateExpirationTime(expiresIn) + let timer + if (timeout) + timer = setTimeout(() => { + abortController.abort() + }, timeout) + fetcher(url, { + method: 'POST', + headers: { + Authorization: `Basic ${basicAuth}`, + 'Content-Length': Buffer.byteLength(body).toString(), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + signal, + }) + .then((res: Response): Promise<*> => { + if (res.ok) + return res + .json() + .then( + ({ + access_token: token, + expires_in: expiresIn, + refresh_token: refreshToken, + }: Object) => { + const expirationTime = calculateExpirationTime(expiresIn) - // Cache new token - tokenCache.set( - { token, expirationTime, refreshToken }, - tokenCacheKey - ) + // Cache new token + tokenCache.set( + { token, expirationTime, refreshToken }, + tokenCacheKey + ) + + // Dispatch all pending requests + requestState.set(false) + + // Freeze and copy pending queue, reset original one for accepting + // new pending tasks + const executionQueue = pendingTasks.slice() + // eslint-disable-next-line no-param-reassign + pendingTasks = [] + executionQueue.forEach((task: Task) => { + // Assign the new token in the request header + const requestWithAuth = mergeAuthHeader(token, task.request) + // console.log('test', cache, pendingTasks) + // Continue by calling the task's own next function + task.next(requestWithAuth, task.response) + }) + } + ) - // Dispatch all pending requests - requestState.set(false) + // Handle error response + return res.text().then((text: any) => { + let parsed + try { + parsed = JSON.parse(text) + } catch (error) { + /* noop */ + } + const error: Object = new Error(parsed ? parsed.message : text) + if (parsed) error.body = parsed - // Freeze and copy pending queue, reset original one for accepting - // new pending tasks - const executionQueue = pendingTasks.slice() - // eslint-disable-next-line no-param-reassign - pendingTasks = [] - executionQueue.forEach((task: Task) => { - // Assign the new token in the request header - const requestWithAuth = mergeAuthHeader(token, task.request) - // console.log('test', cache, pendingTasks) - // Continue by calling the task's own next function - task.next(requestWithAuth, task.response) - }) + // to notify that token is either fetched or failed + // in the below case token failed to be fetched + // and reset requestState to false + // so requestState could be shared between multi authMiddlewareBase functions + requestState.set(false) + + // check that error message matches the pattern '...is suspended' + if (error.message.includes('is suspended')) { + // empty the tokenCache + tokenCache.set(null) + + // retry + if (retryCount < maxRetries) { + setTimeout( + executeFetch, + calcDelayDuration( + retryCount, + retryDelay, + maxRetries, + backoff, + maxDelay + ) + ) + retryCount += 1 + return } - ) - // Handle error response - return res.text().then((text: any) => { - let parsed - try { - parsed = JSON.parse(text) - } catch (error) { - /* noop */ - } - const error: Object = new Error(parsed ? parsed.message : text) - if (parsed) error.body = parsed + // construct a suitable error message for the caller + const errorResponse = { + message: error.body.error, + statusCode: error.body.statusCode, + originalRequest: request, + retryCount, + } + response.reject(errorResponse) + } + response.reject(error) + }) + }) + .catch((error: Error & { type?: string }) => { // to notify that token is either fetched or failed // in the below case token failed to be fetched // and reset requestState to false // so requestState could be shared between multi authMiddlewareBase functions requestState.set(false) - response.reject(error) - }) - }) - .catch((error: Error & { type?: string }) => { - // to notify that token is either fetched or failed - // in the below case token failed to be fetched - // and reset requestState to false - // so requestState could be shared between multi authMiddlewareBase functions - requestState.set(false) + if (response && typeof response.reject === 'function') + response.reject(error) - if (response && typeof response.reject === 'function') - response.reject(error) + if ( + response && + typeof response.reject === 'function' && + error?.type === 'aborted' + ) { + const _error = new NetworkError(error.message, { + type: error.type, + request, + }) + response.reject(_error) + } + }) + .finally(() => { + clearTimeout(timer) + }) + } - if ( - response && - typeof response.reject === 'function' && - error?.type === 'aborted' - ) { - const _error = new NetworkError(error.message, { - type: error.type, - request, - }) - response.reject(_error) - } - }) - .finally(() => { - clearTimeout(timer) - }) + executeFetch() } export default function authMiddlewareBase( @@ -187,6 +246,7 @@ export default function authMiddlewareBase( fetch: fetcher, timeout, getAbortController, + retryConfig, }: AuthMiddlewareBaseOptions, next: Next, userOptions?: AuthMiddlewareOptions | PasswordAuthMiddlewareOptions @@ -253,6 +313,7 @@ export default function authMiddlewareBase( response, timeout, getAbortController, + retryConfig, }) return } @@ -271,5 +332,6 @@ export default function authMiddlewareBase( response, timeout, getAbortController, + retryConfig, }) } diff --git a/packages/sdk-middleware-auth/test/base-auth-flow.spec.js b/packages/sdk-middleware-auth/test/base-auth-flow.spec.js index 6da181f4e..91458fbe0 100644 --- a/packages/sdk-middleware-auth/test/base-auth-flow.spec.js +++ b/packages/sdk-middleware-auth/test/base-auth-flow.spec.js @@ -197,6 +197,141 @@ describe('Base Auth Flow', () => { }) }) + describe('::repeater', () => { + it('should reject response if project is suspended', () => { + const projectKey = process.env.SUS_PROJECT_KEY + const userConfig = { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey, + credentials: { + clientId: process.env.SUS_CLIENT_ID, + clientSecret: process.env.SUS_CLIENT_SECRET, + }, + timeout: 3000, // timeout set to 10ms + getAbortController: () => new AbortController(), + retryConfig: { + maxRetries: 2, + retryDelay: 300, + backoff: false, + }, + scope: [`view_project_settings:${projectKey}`], + fetch, + } + + const client = createClient({ + middlewares: [ + createAuthMiddlewareForClientCredentialsFlow(userConfig), + createHttpMiddleware({ + host: 'https://api.europe-west1.gcp.commercetools.com', + fetch, + }), + ], + }) + + return client + .execute({ + uri: `/${projectKey}`, + method: 'GET', + }) + .catch((error) => { + expect(error.statusCode).toEqual(400) + expect(error.message).toEqual('invalid_scope') + }) + }) + + test('should retry if project was suspended', () => { + const projectKey = process.env.SUS_PROJECT_KEY + const userConfig = { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey, + credentials: { + clientId: process.env.SUS_CLIENT_ID, + clientSecret: process.env.SUS_CLIENT_SECRET, + }, + timeout: 3000, // timeout set to 10ms + getAbortController: () => new AbortController(), + retryConfig: { + maxRetries: 3, + retryDelay: 30, + backoff: true, + }, + tokenCache: store({}), + scope: [`view_project_settings:${projectKey}`], + fetch, + } + + const client = createClient({ + middlewares: [ + createAuthMiddlewareForClientCredentialsFlow(userConfig), + createHttpMiddleware({ + host: 'https://api.europe-west1.gcp.commercetools.com', + fetch, + }), + ], + }) + + return client + .execute({ + uri: `/${projectKey}`, + method: 'GET', + }) + .catch((error) => { + expect(userConfig.tokenCache.get()).toBe(null) + expect(error.statusCode).toEqual(400) + expect(error.headers).toBeUndefined() + expect(error.originalRequest).toBeDefined() + expect(error.retryCount).toBe(3) // same value as the maxRetries + expect(error.message).toBe(`invalid_scope`) + }) + }) + + test('should set tokenCache to null if project is suspended', () => { + const projectKey = process.env.SUS_PROJECT_KEY + const userConfig = { + host: 'https://auth.europe-west1.gcp.commercetools.com', + projectKey, + credentials: { + clientId: process.env.SUS_CLIENT_ID, + clientSecret: process.env.SUS_CLIENT_SECRET, + }, + timeout: 3000, // timeout set to 10ms + getAbortController: () => new AbortController(), + retryConfig: { + maxRetries: 3, + retryDelay: 30, + backoff: true, + }, + tokenCache: store({}), + scope: [`view_project_settings:${projectKey}`], + fetch, + } + + // before executing the request tokenCache should have default value + expect(userConfig.tokenCache.get()).toStrictEqual({}) + const client = createClient({ + middlewares: [ + createAuthMiddlewareForClientCredentialsFlow(userConfig), + createHttpMiddleware({ + host: 'https://api.europe-west1.gcp.commercetools.com', + fetch, + }), + ], + }) + + return client + .execute({ + uri: `/${projectKey}`, + method: 'GET', + }) + .catch((error) => { + // after executing request tokenCache should be null + expect(userConfig.tokenCache.get()).toBe(null) + expect(error.statusCode).toEqual(400) + expect(error.message).toBe(`invalid_scope`) + }) + }) + }) + test('reject if request was not processed within the set timeout', () => new Promise((resolve, reject) => { const response = createTestResponse({ diff --git a/types/sdk.js b/types/sdk.js index 0572d6786..b2cfd2c0a 100644 --- a/types/sdk.js +++ b/types/sdk.js @@ -195,10 +195,17 @@ type requestBaseOptions = { tokenCache: TokenCache, tokenCacheKey?: TokenCacheOptions, timeout?: number, - getAbortController?: () => AbortController + getAbortController?: () => AbortController, + retryConfig?: { + retryDelay: number, + maxRetries: number, + backoff: boolean, + maxDelay: number, + }, } export type executeRequestOptions = requestBaseOptions & { fetcher: typeof fetch, + tokenCache: TokenCache } export type AuthMiddlewareBaseOptions = requestBaseOptions & {