Files
bybit-api/src/util/BaseRestClient.ts

305 lines
7.8 KiB
TypeScript

import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios';
import { signMessage } from './node-support';
import {
RestClientOptions,
serializeParams,
RestClientType,
REST_CLIENT_TYPE_ENUM,
agentSource,
} from './requestUtils';
// axios.interceptors.request.use((request) => {
// console.log(new Date(), 'Starting Request', JSON.stringify(request, null, 2));
// return request;
// });
// axios.interceptors.response.use((response) => {
// console.log(new Date(), 'Response:', JSON.stringify(response, null, 2));
// return response;
// });
interface SignedRequestContext {
timestamp: number;
api_key?: string;
recv_window?: number;
// spot is diff from the rest...
recvWindow?: number;
}
interface SignedRequest<T> {
originalParams: T & SignedRequestContext;
paramsWithSign?: T & SignedRequestContext & { sign: string };
sign: string;
}
export default abstract class BaseRestClient {
private timeOffset: number | null;
private syncTimePromise: null | Promise<any>;
private options: RestClientOptions;
private baseUrl: string;
private globalRequestOptions: AxiosRequestConfig;
private key: string | undefined;
private secret: string | undefined;
private clientType: RestClientType;
/** Function that calls exchange API to query & resolve server time, used by time sync */
abstract fetchServerTime(): Promise<number>;
constructor(
key: string | undefined,
secret: string | undefined,
baseUrl: string,
options: RestClientOptions = {},
requestOptions: AxiosRequestConfig = {},
clientType: RestClientType
) {
this.timeOffset = null;
this.syncTimePromise = null;
this.clientType = clientType;
this.options = {
recv_window: 5000,
/** Throw errors if any params are undefined */
strict_param_validation: false,
/** Disable time sync by default */
enable_time_sync: false,
/** How often to sync time drift with bybit servers (if time sync is enabled) */
sync_interval_ms: 3600000,
...options,
};
this.globalRequestOptions = {
// in ms == 5 minutes by default
timeout: 1000 * 60 * 5,
// custom request options based on axios specs - see: https://github.com/axios/axios#request-config
...requestOptions,
headers: {
'x-referer': 'bybitapinode',
},
};
this.baseUrl = baseUrl;
if (key && !secret) {
throw new Error(
'API Key & Secret are both required for private enpoints'
);
}
if (this.options.enable_time_sync) {
this.syncTime();
setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!);
}
this.key = key;
this.secret = secret;
}
private isSpotClient() {
return this.clientType === REST_CLIENT_TYPE_ENUM.spot;
}
get(endpoint: string, params?: any) {
return this._call('GET', endpoint, params, true);
}
post(endpoint: string, params?: any) {
return this._call('POST', endpoint, params, true);
}
getPrivate(endpoint: string, params?: any) {
return this._call('GET', endpoint, params, false);
}
postPrivate(endpoint: string, params?: any) {
return this._call('POST', endpoint, params, false);
}
deletePrivate(endpoint: string, params?: any) {
return this._call('DELETE', endpoint, params, false);
}
private async prepareSignParams(params?: any, isPublicApi?: boolean) {
if (isPublicApi) {
return {
originalParams: params,
paramsWithSign: params,
};
}
if (!this.key || !this.secret) {
throw new Error('Private endpoints require api and private keys set');
}
if (this.timeOffset === null) {
await this.syncTime();
}
return this.signRequest(params);
}
/**
* @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed.
*/
private async _call(
method: Method,
endpoint: string,
params?: any,
isPublicApi?: boolean
): Promise<any> {
const options = {
...this.globalRequestOptions,
url: [this.baseUrl, endpoint].join(endpoint.startsWith('/') ? '' : '/'),
method: method,
json: true,
};
for (const key in params) {
if (typeof params[key] === 'undefined') {
delete params[key];
}
}
const signResult = await this.prepareSignParams(params, isPublicApi);
if (method === 'GET' || this.isSpotClient()) {
options.params = signResult.paramsWithSign;
if (options.params?.agentSource) {
options.data = {
agentSource: agentSource,
};
}
} else {
options.data = signResult.paramsWithSign;
}
return axios(options)
.then((response) => {
if (response.status == 200) {
return response.data;
}
throw response;
})
.catch((e) => this.parseException(e));
}
/**
* @private generic handler to parse request exceptions
*/
parseException(e: any): unknown {
if (this.options.parse_exceptions === false) {
throw e;
}
// Something happened in setting up the request that triggered an Error
if (!e.response) {
if (!e.request) {
throw e.message;
}
// request made but no response received
throw e;
}
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
const response: AxiosResponse = e.response;
throw {
code: response.status,
message: response.statusText,
body: response.data,
headers: response.headers,
requestOptions: this.options,
};
}
/**
* @private sign request and set recv window
*/
private async signRequest<T extends Object>(
data: T & SignedRequestContext
): Promise<SignedRequest<T>> {
const res: SignedRequest<T> = {
originalParams: {
...data,
api_key: this.key,
timestamp: Date.now() + (this.timeOffset || 0),
},
sign: '',
};
// Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen.
if (this.options.recv_window && !res.originalParams.recv_window) {
if (this.isSpotClient()) {
res.originalParams.recvWindow = this.options.recv_window;
} else {
res.originalParams.recv_window = this.options.recv_window;
}
}
if (this.key && this.secret) {
const serializedParams = serializeParams(
res.originalParams,
this.options.strict_param_validation
);
res.sign = await signMessage(serializedParams, this.secret);
res.paramsWithSign = {
...res.originalParams,
sign: res.sign,
};
}
return res;
}
/**
* Trigger time sync and store promise. Use force: true, if automatic time sync is disabled
*/
private syncTime(force?: boolean): Promise<any> {
if (!force && !this.options.enable_time_sync) {
this.timeOffset = 0;
return Promise.resolve(false);
}
if (this.syncTimePromise !== null) {
return this.syncTimePromise;
}
this.syncTimePromise = this.fetchTimeOffset().then((offset) => {
this.timeOffset = offset;
this.syncTimePromise = null;
});
return this.syncTimePromise;
}
/**
* Estimate drift based on client<->server latency
*/
async fetchTimeOffset(): Promise<number> {
try {
const start = Date.now();
const serverTime = await this.fetchServerTime();
if (!serverTime || isNaN(serverTime)) {
throw new Error(
`fetchServerTime() returned non-number: "${serverTime}" typeof(${typeof serverTime})`
);
}
const end = Date.now();
const severTimeMs = serverTime * 1000;
const avgDrift = (end - start) / 2;
return Math.ceil(severTimeMs - end + avgDrift);
} catch (e) {
console.error('Failed to fetch get time offset: ', e);
return 0;
}
}
}