diff --git a/package-lock.json b/package-lock.json index 449e85c..0d1d686 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ulms/api-clients", - "version": "7.5.0", + "version": "7.5.0-dev.0-ulms-2596", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ulms/api-clients", - "version": "7.5.0", + "version": "7.5.0-dev.0-ulms-2596", "license": "MIT", "dependencies": { "axios": "1.6.2", diff --git a/package.json b/package.json index e027e5e..3103e82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ulms/api-clients", - "version": "7.5.0", + "version": "7.5.0-dev.0-ulms-2596", "description": "JavaScript API clients for ULMS platform", "keywords": [], "homepage": "https://github.com/foxford/ulms-api-clients-js#readme", diff --git a/src/basic-client.js b/src/basic-client.js index ce0a273..f17e92c 100644 --- a/src/basic-client.js +++ b/src/basic-client.js @@ -1,3 +1,7 @@ +import retry, { isErrorRetryable } from './retry' + +const onRetry = (error) => !isErrorRetryable(error) + const parseParameter = (key, value) => { if (Array.isArray(value)) { // eslint-disable-next-line unicorn/no-array-reduce @@ -22,6 +26,8 @@ const parseParameter = (key, value) => { return `${key}=${value}` } +const responseTransformer = (_) => _.data + class BasicClient { constructor(baseUrl, httpClient, tokenProvider) { this.baseUrl = baseUrl @@ -86,6 +92,25 @@ class BasicClient { } async get(url, options = {}) { + const { retry: retryEnabled } = options + + if (retryEnabled) { + const task = async () => { + const token = await this.tokenProvider.getToken() + const headers = { + ...options.headers, + ...BasicClient.headers(token, this.labels, this.customHeaders), + } + const requestOptions = { ...options, headers } + + return this.httpClient + .get(url, requestOptions) + .then(responseTransformer) + } + + return retry(task, onRetry) + } + const token = await this.tokenProvider.getToken() const headers = { ...options.headers, @@ -93,7 +118,7 @@ class BasicClient { } const requestOptions = { ...options, headers } - return this.httpClient.get(url, requestOptions) + return this.httpClient.get(url, requestOptions).then(responseTransformer) } async put(url, data, options = {}) { @@ -104,7 +129,9 @@ class BasicClient { } const requestOptions = { ...options, headers } - return this.httpClient.put(url, data, requestOptions) + return this.httpClient + .put(url, data, requestOptions) + .then(responseTransformer) } async post(url, data, options = {}) { @@ -122,7 +149,9 @@ class BasicClient { const expiresAtLocal = this.tokenProvider.tokenData ? this.tokenProvider.tokenData.expires_ts : undefined - const result = this.httpClient.post(url, data, requestOptions) + const result = this.httpClient + .post(url, data, requestOptions) + .then(responseTransformer) result.catch((error) => { const responseEnd = Date.now() @@ -143,7 +172,9 @@ class BasicClient { } // [debug section] end - return this.httpClient.post(url, data, requestOptions) + return this.httpClient + .post(url, data, requestOptions) + .then(responseTransformer) } async patch(url, data, options = {}) { @@ -154,7 +185,9 @@ class BasicClient { } const requestOptions = { ...options, headers } - return this.httpClient.patch(url, data, requestOptions) + return this.httpClient + .patch(url, data, requestOptions) + .then(responseTransformer) } async delete(url, options = {}) { @@ -165,7 +198,7 @@ class BasicClient { } const requestOptions = { ...options, headers } - return this.httpClient.delete(url, requestOptions) + return this.httpClient.delete(url, requestOptions).then(responseTransformer) } } diff --git a/src/http-client.js b/src/http-client.js index bd75322..ae1048b 100644 --- a/src/http-client.js +++ b/src/http-client.js @@ -1,65 +1,92 @@ /* eslint-disable class-methods-use-this */ +function createTimeoutSignal(timeout) { + const controller = new AbortController() + const { signal } = controller + const id = setTimeout(() => controller.abort(), timeout) + const cleanup = () => clearTimeout(id) + + return { cleanup, signal } +} + class FetchHttpClient { - static handleResponse(response) { + static async handleResponse(response) { + const bodyAsText = await response.text() + let data + + try { + data = JSON.parse(bodyAsText) + } catch { + data = { message: bodyAsText } + } + + const result = { + data, + status: response.status, + } + if (!response.ok) { - return response - .json() - .catch(() => response.text()) - .catch(() => ({ - status: response.status, - statusText: response.statusText, - })) - .then((result) => { - throw result - }) + throw result + } + + return result + } + + request(url, config) { + const { timeout, ...requestConfig } = config + const requestOptions = { + ...requestConfig, + } + let onFinally + + if (timeout !== undefined) { + const { cleanup, signal } = createTimeoutSignal(timeout) + + requestOptions.signal = signal + onFinally = cleanup } - return response - .json() - .catch(() => response.text()) - .catch(() => ({ - status: response.status, - statusText: response.statusText, - })) + return fetch(url, requestOptions) + .then(FetchHttpClient.handleResponse) + .finally(() => (onFinally ? onFinally() : undefined)) } get(url, config) { - return fetch(url, { + return this.request(url, { ...config, method: 'GET', - }).then(FetchHttpClient.handleResponse) + }) } put(url, data, config) { - return fetch(url, { + return this.request(url, { ...config, method: 'PUT', body: JSON.stringify(data), - }).then(FetchHttpClient.handleResponse) + }) } post(url, data, config) { - return fetch(url, { + return this.request(url, { ...config, method: 'POST', body: JSON.stringify(data), - }).then(FetchHttpClient.handleResponse) + }) } patch(url, data, config) { - return fetch(url, { + return this.request(url, { ...config, method: 'PATCH', body: JSON.stringify(data), - }).then(FetchHttpClient.handleResponse) + }) } delete(url, config) { - return fetch(url, { + return this.request(url, { ...config, method: 'DELETE', - }).then(FetchHttpClient.handleResponse) + }) } } diff --git a/src/profile.js b/src/profile.js index 010a657..5ed3709 100644 --- a/src/profile.js +++ b/src/profile.js @@ -1,3 +1,7 @@ +/* eslint-disable promise/no-nesting */ +const responseTransformer = (_) => _.data + +// todo: extend from BasicClient class HttpProfileResource { constructor(host, endpoint, httpClient, tokenProvider) { this.baseUrl = `${host}/${endpoint}` @@ -25,9 +29,11 @@ class HttpProfileResource { } return this.tokenProvider.getToken().then((token) => - this.httpClient.get(`${this.baseUrl}/users/${id}${qs}`, { - headers: HttpProfileResource.headers({ token }), - }), + this.httpClient + .get(`${this.baseUrl}/users/${id}${qs}`, { + headers: HttpProfileResource.headers({ token }), + }) + .then(responseTransformer), ) } @@ -35,9 +41,11 @@ class HttpProfileResource { const qs = `?ids=${ids.join(',')}&scope=${scope}` return this.tokenProvider.getToken().then((token) => - this.httpClient.get(`${this.baseUrl}/users${qs}`, { - headers: HttpProfileResource.headers({ token }), - }), + this.httpClient + .get(`${this.baseUrl}/users${qs}`, { + headers: HttpProfileResource.headers({ token }), + }) + .then(responseTransformer), ) } @@ -45,9 +53,11 @@ class HttpProfileResource { const qs = `?scope=${scope}` return this.tokenProvider.getToken().then((token) => - this.httpClient.patch(`${this.baseUrl}/users/${id}${qs}`, data, { - headers: HttpProfileResource.headers({ token }), - }), + this.httpClient + .patch(`${this.baseUrl}/users/${id}${qs}`, data, { + headers: HttpProfileResource.headers({ token }), + }) + .then(responseTransformer), ) } } diff --git a/src/retry.js b/src/retry.js new file mode 100644 index 0000000..79e3388 --- /dev/null +++ b/src/retry.js @@ -0,0 +1,70 @@ +import Backoff from './backoff' +import { sleep } from './common' + +const RETRY_LIMIT = 3 + +async function retry(task, onRetry, retryLimit = RETRY_LIMIT) { + const backoff = new Backoff() + let reason + let result + let retryCount = 0 + + while (retryCount < retryLimit) { + if (retryCount > 0) { + if (onRetry) { + // eslint-disable-next-line no-await-in-loop + const stop = onRetry(reason, retryCount) + + if (stop) break + } + + // eslint-disable-next-line no-await-in-loop + await sleep(backoff.value) + + backoff.next() + } + + try { + // eslint-disable-next-line no-await-in-loop + result = await task() + } catch (error) { + reason = error + } + + if (result) break + + retryCount += 1 + } + + backoff.reset() + + if (result) return result + + throw reason +} + +function isErrorRetryable(error) { + /* + Повторная попытка разрешена для следующих ошибок: + - [+] клиентская сетевая ошибка ("Failed to fetch *") + - [+] таймаут запроса + - [+] HTTP ответ со статус-кодом: 422, 424, 429 или 5xx + */ + const isNetworkError = + error instanceof TypeError && error.message.startsWith('Failed to fetch') + const isTimeoutError = + error instanceof DOMException && error.name === 'AbortError' + const isPassedByStatusCode = error.status + ? error.status === 422 || + error.status === 401 || + error.status === 424 || + error.status === 429 || + error.status >= 500 + : false + + return isNetworkError || isTimeoutError || isPassedByStatusCode +} + +export default retry + +export { isErrorRetryable } diff --git a/src/token-provider.js b/src/token-provider.js index 2c1b5ef..967238d 100644 --- a/src/token-provider.js +++ b/src/token-provider.js @@ -1,6 +1,9 @@ /* eslint-disable camelcase, promise/always-return */ import { makeDeferred } from './common' import { TokenProviderError } from './error' +import retry, { isErrorRetryable } from './retry' + +const onRetry = (error) => !isErrorRetryable(error) class TokenProvider { constructor(baseUrl, httpClient) { @@ -37,7 +40,7 @@ class TokenProvider { this.fetchTokenData() .then((response) => { - this.updateTokenData(response) + this.updateTokenData(response.data) this.resolveAndReset() }) .catch((error) => { @@ -50,24 +53,19 @@ class TokenProvider { return Promise.resolve(this.tokenData.access_token) } - fetchTokenData() { - const controller = new AbortController() - const { signal } = controller - const timeoutId = setTimeout(() => controller.abort(), 10 * 1000) + async fetchTokenData() { const qs = this.context ? `?context=${this.context}` : '' const url = `${this.baseUrl}/api/user/ulms_token${qs}` - - return this.httpClient - .post(url, undefined, { + const task = async () => + this.httpClient.post(url, undefined, { credentials: 'include', headers: { 'X-Referer': `${window.location.origin}${window.location.pathname}`, }, - signal, - }) - .finally(() => { - clearTimeout(timeoutId) + timeout: 10_000, }) + + return retry(task, onRetry) } rejectAndReset(error) { @@ -102,10 +100,10 @@ class TokenProvider { 'Request was aborted (client timeout)', error, ) - } else if (error.error) { + } else if (error.data && error.data.error) { transformedError = new TokenProviderError( TokenProviderError.types.UNAUTHENTICATED, - error.error, + error.data.error, error, ) } else { diff --git a/src/ulms.js b/src/ulms.js index 4bd385f..61eacac 100644 --- a/src/ulms.js +++ b/src/ulms.js @@ -104,6 +104,7 @@ class ULMS extends BasicClient { readScope(kind, audience, scope, options) { return this.get( this.url(`/audiences/${audience}/${kind}/${scope}`, options), + { timeout: 10_000, retry: true }, ) }