UNPKG

metaapi.cloud-sdk

Version:

SDK for MetaApi, a professional cloud forex API which includes MetaTrader REST API and MetaTrader websocket API. Supports both MetaTrader 5 (MT5) and MetaTrader 4 (MT4). CopyFactory copy trading API included. (https://metaapi.cloud)

230 lines (196 loc) 7.12 kB
'use strict'; const axios = require('@axios'); import { UnauthorizedError, ForbiddenError, ApiError, ValidationError, InternalError, NotFoundError, TooManyRequestsError, ConflictError } from './errorHandler'; import OptionsValidator from './optionsValidator'; import TimeoutError from './timeoutError'; import LoggerManager, {Logger} from '../logger'; import _ from 'lodash'; /** * HTTP client library based on axios */ export default class HttpClient { private _timeout: number; private _retries: any; private _minRetryDelay: number; private _maxRetryDelay: number; private _longRunningRequestTimeout: number; private _logger: Logger; /** * Constructs HttpClient class instance * @param {Number} timeout request timeout in seconds * @param {RetryOptions} [retryOpts] retry options */ constructor(timeout = 60, retryOpts: RetryOptions = {}) { const validator = new OptionsValidator(); this._timeout = timeout * 1000; this._retries = validator.validateNumber(retryOpts.retries, 5, 'retryOpts.retries'); this._minRetryDelay = validator.validateNonZero(retryOpts.minDelayInSeconds, 1, 'retryOpts.minDelayInSeconds') * 1000; this._maxRetryDelay = validator.validateNonZero(retryOpts.maxDelayInSeconds, 30, 'retryOpts.maxDelayInSeconds') * 1000; this._longRunningRequestTimeout = validator.validateNumber(retryOpts.longRunningRequestTimeoutInMinutes, 10, 'retryOpts.longRunningRequestTimeoutInMinutes') * 60 * 1000; this._logger = LoggerManager.getLogger('HttpClient'); } /** * Performs a request. Response errors are returned as ApiError or subclasses. * @param {Object} options request options * @returns {Object|String|any} request result */ async request<T = any>( options, type = '', retryCounter = 0, endTime = Date.now() + this._maxRetryDelay * this._retries, isLongRunning = false ): Promise<T> { options.timeout = this._timeout; let retryAfterSeconds = 0; options.callback = (e, res) => { this._logger.debug(`${type}: received request response with status ${res?.status}`); if (res?.status === 202) { retryAfterSeconds = res.headers['retry-after'] ?? res.data?.metadata?.recommendedRetryTime; this._logger.debug(`${type}: retry after value is ${retryAfterSeconds}`); if (isNaN(retryAfterSeconds)) { retryAfterSeconds = Math.max((new Date(retryAfterSeconds).getTime() - Date.now()) / 1000, 1); } if (!isLongRunning) { endTime = Date.now() + this._longRunningRequestTimeout; isLongRunning = true; } } }; let body; try { const response = await this._makeRequest(options, type); options.callback(null, response); body = (response && response.data) || undefined; } catch (err) { retryCounter = await this._handleError(err, type, retryCounter, endTime); return this.request(options, type, retryCounter, endTime); } if (retryAfterSeconds) { if (body && body.message) { this._logger.info(`Retrying request in ${Math.floor(retryAfterSeconds)} seconds because request ` + 'returned message:', body.message); } await this._handleRetry(endTime, retryAfterSeconds * 1000); body = await this.request(options, type, retryCounter, endTime, isLongRunning); } return body; } _makeRequest(options, type) { let optionsToLog = _.cloneDeep(options); if (optionsToLog.headers?.['auth-token']) { optionsToLog.headers['auth-token'] = '...'; } this._logger.debug(`${type}: sending a request with options`, JSON.stringify(optionsToLog)); return axios({ transitional: { clarifyTimeoutError: true }, ...options }); } async _wait(pause) { await new Promise(res => setTimeout(res, pause)); } async _handleRetry(endTime, retryAfter) { if (endTime > Date.now() + retryAfter) { await this._wait(retryAfter); } else { throw new TimeoutError('Timed out waiting for the response'); } } async _handleError(err, type, retryCounter, endTime) { const error = this._convertError(err); if ( ['ConflictError', 'InternalError', 'ApiError', 'TimeoutError'].includes(error.name) && retryCounter < this._retries ) { const pause = Math.min(Math.pow(2, retryCounter) * this._minRetryDelay, this._maxRetryDelay); await this._wait(pause); return retryCounter + 1; } else if (error.name === 'TooManyRequestsError') { const retryTime = new Date((error as TooManyRequestsError).metadata.recommendedRetryTime).getTime(); if (retryTime < endTime) { this._logger.debug(`${type} request has failed with TooManyRequestsError (HTTP status code 429). ` + `Will retry request in ${Math.ceil((retryTime - Date.now()) / 1000)} seconds`); await this._wait(retryTime - Date.now()); return retryCounter; } } throw error; } // eslint-disable-next-line complexity _convertError(err) { const errorResponse = err.response || {}; const errorData = errorResponse.data || {}; const status = errorResponse.status || err.status; const url = err?.config?.url; const errMsg = errorData.message || err.message; const errMsgDefault = errorData.message || err.code || err.message; switch (status) { case 400: return new ValidationError(errMsg, errorData.details || err.details, url); case 401: return new UnauthorizedError(errMsg, url); case 403: return new ForbiddenError(errMsg, url); case 404: return new NotFoundError(errMsg, url); case 409: return new ConflictError(errMsg, url); case 429: return new TooManyRequestsError(errMsg, errorData.metadata || err.metadata, url); case 500: return new InternalError(errMsg, url); default: return new ApiError(ApiError, errMsgDefault, status, url); } } } /** * HTTP client service mock for tests */ export class HttpClientMock extends HttpClient { _requestFn: any; /** * Constructs HTTP client mock * @param {Function(options:Object):Promise} requestFn mocked request function * @param {Number} timeout request timeout in seconds * @param {RetryOptions} retryOpts retry options */ constructor(requestFn, timeout, retryOpts) { super(timeout, retryOpts); this._requestFn = requestFn; } _makeRequest(...args) { return this._requestFn(...args); } } /** * retry options */ export declare type RetryOptions = { /** * the number of attempts to retry failed request, default 5 */ retries?: number, /** * minimum delay in seconds before retrying, default 1 */ minDelayInSeconds?: number, /** * maximum delay in seconds before retrying, default 30 */ maxDelayInSeconds?: number, /** * timeout in minutes for long running requests, default 10 */ longRunningRequestTimeoutInMinutes?: number, /** * time to disable new subscriptions for */ subscribeCooldownInSeconds?: number }