diff --git a/src/utils/Xhr.js b/src/utils/Xhr.js.flow similarity index 99% rename from src/utils/Xhr.js rename to src/utils/Xhr.js.flow index 586eda1570..bfc2654012 100644 --- a/src/utils/Xhr.js +++ b/src/utils/Xhr.js.flow @@ -380,7 +380,7 @@ class Xhr { progressHandler?: Function, successHandler: Function, url: string, - }): Promise { + }): Promise { return this.getHeaders(id, headers) .then(hdrs => this.axios({ diff --git a/src/utils/Xhr.ts b/src/utils/Xhr.ts new file mode 100644 index 0000000000..6a987d2450 --- /dev/null +++ b/src/utils/Xhr.ts @@ -0,0 +1,404 @@ +import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse, CancelTokenSource } from 'axios'; +import getProp from 'lodash/get'; +import includes from 'lodash/includes'; +import lowerCase from 'lodash/lowerCase'; +import TokenService from './TokenService'; +import { + HEADER_ACCEPT, + HEADER_ACCEPT_LANGUAGE, + HEADER_CLIENT_NAME, + HEADER_CLIENT_VERSION, + HEADER_CONTENT_TYPE, + HTTP_GET, + HTTP_POST, + HTTP_PUT, + HTTP_DELETE, + HTTP_OPTIONS, + HTTP_HEAD, + HTTP_STATUS_CODE_RATE_LIMIT, +} from '../constants'; +import type { APIOptions, Method, PayloadType, RequestData } from '../common/types/api'; +import type { StringAnyMap, StringMap, Token } from '../common/types/core'; + +const DEFAULT_UPLOAD_TIMEOUT_MS = 120000; +const MAX_NUM_RETRIES = 3; +const RETRYABLE_HTTP_METHODS = [HTTP_GET, HTTP_OPTIONS, HTTP_HEAD].map(lowerCase); + +class Xhr { + id: string | null | undefined; + + axios: AxiosInstance; + + axiosSource: CancelTokenSource; + + clientName: string | null | undefined; + + language: string | null | undefined; + + token: Token; + + version: string | null | undefined; + + sharedLink: string | null | undefined; + + sharedLinkPassword: string | null | undefined; + + xhr: XMLHttpRequest; + + responseInterceptor: (response: AxiosResponse) => AxiosResponse | Promise; + + requestInterceptor: + | ((config: AxiosRequestConfig) => AxiosRequestConfig | Promise) + | null + | undefined; + + tokenService: TokenService; + + retryCount: number = 0; + + retryableStatusCodes: Array; + + retryTimeout: ReturnType | null | undefined; + + shouldRetry: boolean; + + constructor({ + id, + clientName, + language, + token, + version, + sharedLink, + sharedLinkPassword, + responseInterceptor, + requestInterceptor, + retryableStatusCodes = [HTTP_STATUS_CODE_RATE_LIMIT], + shouldRetry = true, + }: APIOptions = {}) { + this.clientName = clientName; + this.id = id; + this.language = language; + this.responseInterceptor = responseInterceptor || this.defaultResponseInterceptor; + this.retryableStatusCodes = retryableStatusCodes; + this.sharedLink = sharedLink; + this.sharedLinkPassword = sharedLinkPassword; + this.shouldRetry = shouldRetry; + this.token = token; + this.version = version; + + this.axios = axios.create(); + this.axiosSource = axios.CancelToken.source(); + this.axios.interceptors.response.use(this.responseInterceptor, this.errorInterceptor); + + if (typeof requestInterceptor === 'function') { + this.axios.interceptors.request.use(requestInterceptor); + } + } + + defaultResponseInterceptor(response: AxiosResponse): AxiosResponse { + return response; + } + + shouldRetryRequest(error: AxiosError): boolean { + if (!this.shouldRetry || this.retryCount >= MAX_NUM_RETRIES) { + return false; + } + + const { response, request, config } = error; + // Retry if there is a network error (e.g. ECONNRESET) or rate limited + const status = getProp(response, 'status'); + const method = getProp(config, 'method'); + const isNetworkError = request && !response; + const isRateLimitError = status === HTTP_STATUS_CODE_RATE_LIMIT; + const isOtherRetryableError = + includes(this.retryableStatusCodes, status) && includes(RETRYABLE_HTTP_METHODS, method); + return isNetworkError || isRateLimitError || isOtherRetryableError; + } + + /** Calculate the exponential backoff time with randomized jitter. */ + getExponentialRetryTimeoutInMs(numRetries: number): number { + const randomizationMs = Math.ceil(Math.random() * 1000); + const exponentialMs = 2 ** (numRetries - 1) * 1000; + return exponentialMs + randomizationMs; + } + + /** Error interceptor that wraps the passed in responseInterceptor. */ + errorInterceptor = (error: AxiosError): Promise => { + const shouldRetry = this.shouldRetryRequest(error); + if (shouldRetry) { + this.retryCount += 1; + const delay = this.getExponentialRetryTimeoutInMs(this.retryCount); + return new Promise((resolve, reject) => { + this.retryTimeout = setTimeout(() => { + this.axios(error.config).then(resolve, reject); + }, delay); + }); + } + + const errorObject = getProp(error, 'response.data') || error; // In the case of 401, response.data is empty so fall back to error + this.responseInterceptor(errorObject as AxiosResponse); + + return Promise.reject(error); + }; + + getParsedUrl(url: string): { + api: string; + hash: string; + host: string; + hostname: string; + origin: string; + pathname: string; + port: string; + protocol: string; + } { + const a = document.createElement('a'); + a.href = url; + return { + api: url.replace(`${a.origin}/2.0`, ''), + host: a.host, + hostname: a.hostname, + pathname: a.pathname, + origin: a.origin, + protocol: a.protocol, + hash: a.hash, + port: a.port, + }; + } + + async getHeaders(id?: string, args: StringMap = {}): Promise { + const headers: StringMap = { + Accept: 'application/json', + [HEADER_CONTENT_TYPE]: 'application/json', + ...args, + }; + + if (this.language && !headers[HEADER_ACCEPT_LANGUAGE]) { + headers[HEADER_ACCEPT_LANGUAGE] = this.language; + } + + if (this.sharedLink) { + headers.BoxApi = `shared_link=${this.sharedLink}`; + + if (this.sharedLinkPassword) { + headers.BoxApi = `${headers.BoxApi}&shared_link_password=${this.sharedLinkPassword}`; + } + } + + if (this.clientName) { + headers[HEADER_CLIENT_NAME] = this.clientName; + } + + if (this.version) { + headers[HEADER_CLIENT_VERSION] = this.version; + } + + // If id is passed in, use that, otherwise default to this.id + const itemId = id || this.id || ''; + const token = await TokenService.getWriteToken(itemId, this.token); + if (token) { + // Only add a token when there was one found + headers.Authorization = `Bearer ${token}`; + } + + return headers; + } + + get({ + url, + id, + params = {}, + headers = {}, + }: { + headers?: StringMap; + id?: string; + params?: StringAnyMap; + url: string; + }): Promise { + return this.getHeaders(id, headers).then(hdrs => + this.axios.get(url, { + cancelToken: this.axiosSource.token, + params, + headers: hdrs, + parsedUrl: this.getParsedUrl(url), + } as AxiosRequestConfig), + ); + } + + post({ + url, + id, + data, + params, + headers = {}, + method = HTTP_POST, + }: { + data: PayloadType; + headers?: StringMap; + id?: string; + method?: Method; + params?: StringAnyMap; + url: string; + }): Promise { + return this.getHeaders(id, headers).then(hdrs => + this.axios({ + url, + data, + params, + method, + parsedUrl: this.getParsedUrl(url), + headers: hdrs, + } as AxiosRequestConfig), + ); + } + + put({ url, id, data, params, headers = {} }: RequestData): Promise { + return this.post({ id, url, data, params, headers, method: HTTP_PUT }); + } + + delete({ + url, + id, + data = {}, + headers = {}, + }: { + data?: StringAnyMap; + headers?: StringMap; + id?: string; + url: string; + }): Promise { + return this.post({ id, url, data, headers, method: HTTP_DELETE }); + } + + options({ + id, + url, + data, + headers = {}, + successHandler, + errorHandler, + }: { + data: StringAnyMap; + errorHandler: (error: unknown) => void; + headers?: StringMap; + id?: string; + progressHandler?: (event: ProgressEvent) => void; + successHandler: (response: AxiosResponse) => void; + url: string; + }): Promise { + return this.getHeaders(id, headers) + .then(hdrs => + this.axios({ + url, + data, + method: HTTP_OPTIONS, + headers: hdrs, + }) + .then(successHandler) + .catch(errorHandler), + ) + .catch(errorHandler); + } + + uploadFile({ + id, + url, + data, + headers = {}, + method = HTTP_POST, + successHandler, + errorHandler, + progressHandler, + withIdleTimeout = false, + idleTimeoutDuration = DEFAULT_UPLOAD_TIMEOUT_MS, + idleTimeoutHandler, + }: { + data?: Blob | StringAnyMap | null; + errorHandler: (error: unknown) => void; + headers?: StringMap; + id?: string; + idleTimeoutDuration?: number; + idleTimeoutHandler?: () => void; + method?: Method; + progressHandler: (event: ProgressEvent) => void; + successHandler: (response: AxiosResponse) => void; + url: string; + withIdleTimeout?: boolean; + }): Promise { + return this.getHeaders(id, headers) + .then(hdrs => { + let idleTimeout; + let progressHandlerToUse = progressHandler; + + if (withIdleTimeout) { + // Func that aborts upload and executes timeout callback + const idleTimeoutFunc = () => { + this.abort(); + + if (idleTimeoutHandler) { + idleTimeoutHandler(); + } + }; + + idleTimeout = setTimeout(idleTimeoutFunc, idleTimeoutDuration); + + // Progress handler that aborts upload if there has been no progress for >= timeoutMs + progressHandlerToUse = event => { + clearTimeout(idleTimeout); + idleTimeout = setTimeout(idleTimeoutFunc, idleTimeoutDuration); + progressHandler(event); + }; + } + this.axios({ + url, + data, + transformRequest: (reqData, reqHeaders) => { + // Remove Accept & Content-Type added by getHeaders() + delete reqHeaders[HEADER_ACCEPT]; + delete reqHeaders[HEADER_CONTENT_TYPE]; + + if (headers[HEADER_CONTENT_TYPE]) { + reqHeaders[HEADER_CONTENT_TYPE] = headers[HEADER_CONTENT_TYPE]; + } + + // Convert to FormData if needed + if (reqData && !(reqData instanceof Blob) && reqData.attributes) { + const formData = new FormData(); + Object.keys(reqData).forEach(key => { + formData.append(key, reqData[key]); + }); + + return formData; + } + + return reqData; + }, + method, + headers: hdrs, + onUploadProgress: progressHandlerToUse, + cancelToken: this.axiosSource.token, + } as AxiosRequestConfig) + .then(response => { + clearTimeout(idleTimeout); + successHandler(response); + }) + .catch(error => { + clearTimeout(idleTimeout); + errorHandler(error); + }); + }) + .catch(errorHandler); + } + + /** Aborts an axios request. */ + abort(): void { + if (this.retryTimeout) { + clearTimeout(this.retryTimeout); + } + if (this.axiosSource) { + this.axiosSource.cancel(); + this.axiosSource = axios.CancelToken.source(); + } + } +} + +export default Xhr; diff --git a/src/utils/__tests__/Xhr.test.js b/src/utils/__tests__/Xhr.test.ts similarity index 98% rename from src/utils/__tests__/Xhr.test.js rename to src/utils/__tests__/Xhr.test.ts index f44a12b8ce..02e5bc5703 100644 --- a/src/utils/__tests__/Xhr.test.js +++ b/src/utils/__tests__/Xhr.test.ts @@ -3,7 +3,7 @@ import TokenService from '../TokenService'; import Xhr from '../Xhr'; jest.mock('../TokenService'); -TokenService.getReadToken.mockImplementation(() => Promise.resolve(`${Math.random()}`)); +(TokenService.getReadToken as jest.Mock).mockImplementation(() => Promise.resolve(`${Math.random()}`)); describe('util/Xhr', () => { let xhrInstance; @@ -375,10 +375,7 @@ describe('util/Xhr', () => { }, }; xhrInstance.axios = jest.fn().mockImplementation(() => { - xhrInstance - .errorInterceptor(error) - .then(() => {}) - .catch(() => {}); + xhrInstance.errorInterceptor(error).then(noop).catch(noop); return Promise.resolve(); }); // first time return true, then false