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
text/typescript
;
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
}