@getanthill/datastore
Version:
Event-Sourced Datastore
224 lines (188 loc) • 5.84 kB
text/typescript
import type { metrics } from '@getanthill/telemetry';
import type { Telemetry } from '../typings';
import { EventEmitter } from 'events';
import axios, {
AxiosError,
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
} from 'axios';
import qs from 'qs';
import merge from 'lodash/merge';
export interface CoreConfig {
token: string;
baseUrl?: string;
timeout?: number;
debug?: boolean;
telemetry?: any; // @getanthill/telemetry
retriableMethods: Array<string>;
retriableErrors: Array<string>;
maxRetry: number;
forcePrimary?: true;
}
export default class Core extends EventEmitter {
static ERRORS = {};
static DEFAULT_RETRIABLE_METHODS = ['get', 'head', 'options'];
static DEFAULT_RETRIABLE_ERRORS = ['socket hang up'];
static DEFAULT_MAX_RETRY = 3;
static DEFAULT_HEADERS = Object.freeze({
'Content-Type': 'application/json',
Accept: 'application/json',
});
private _config: CoreConfig = {
baseUrl: 'http://localhost:3001',
timeout: 10000,
token: 'token',
debug: false,
retriableMethods: Core.DEFAULT_RETRIABLE_METHODS,
retriableErrors: Core.DEFAULT_RETRIABLE_ERRORS,
maxRetry: Core.DEFAULT_MAX_RETRY,
};
private _axios: AxiosInstance;
private _telemetry: Telemetry | null = null;
private _recordHttpRequestDuration:
| undefined
| ReturnType<typeof metrics.createHistogram>;
constructor(config: Partial<CoreConfig> = {}) {
super();
this._config = merge({}, this._config, config);
this._axios = axios.create({
baseURL: this._config.baseUrl,
timeout: this._config.timeout,
withCredentials: true,
headers: {
...Core.DEFAULT_HEADERS,
Authorization: this._config.token,
},
paramsSerializer: {
serialize: Core.paramsSerializer,
},
});
// @ts-ignore
this._axios.interceptors.response.use(
(response) => response,
this.responseInterceptor.bind(this),
);
if (this._config.telemetry) {
this._telemetry = this._config.telemetry;
this._recordHttpRequestDuration =
this._telemetry?.metrics?.createHistogram(
'datastore_core_request_duration_ms', // name
'Duration of the Datastore Core SDK HTTP requests in ms', // description
[1, 2, 3, 5, 10, 25, 50, 100, 250, 1000], // buckets
'ms', // unit
);
}
}
static paramsSerializer(params: { [key: string]: any }) {
const p = params;
if ('_q' in p) {
p._q = JSON.stringify(p._q);
}
return (
'q=' +
encodeURIComponent(JSON.stringify(params)) +
'&' +
qs.stringify(p, { arrayFormat: 'brackets' })
);
}
private cloneAxiosError(err: AxiosError): Error {
const _err = new Error(err.message);
_err.name = err.name;
// @ts-ignore
_err.code = err.code;
// @ts-ignore
_err.config = err.config;
// @ts-ignore
_err.response = err.response;
// @ts-ignore
_err.response && (_err.response.request = undefined);
return _err;
}
setTimeout(timeout: number) {
this._axios.defaults.timeout = timeout;
}
getPath(...fragments: (string | number | Date)[]) {
return '/' + ['api', ...fragments].join('/');
}
inspect(obj: { [key: string]: any }) {
if (this._config.debug === true) {
console.log(require('util').inspect(obj, false, null, true));
}
return obj;
}
async request(config: AxiosRequestConfig): Promise<AxiosResponse> {
const [domain] = config.url?.substring(5).split('/', 1) as string[];
const tic = Date.now();
try {
this._telemetry?.metric('datastore_core_request_total', {
domain,
state: 'request',
method: config.method as string,
});
config.headers = {
...config.headers,
'force-primary':
config.headers?.['force-primary'] ?? this._config.forcePrimary,
};
const res = await this._axios.request(config);
this._recordHttpRequestDuration?.(Date.now() - tic, {
domain,
state: 'success',
method: config.method as string,
});
this._telemetry?.metric('datastore_core_request_total', {
domain,
state: 'success',
method: config.method as string,
});
return res;
} catch (err: any) {
this._recordHttpRequestDuration?.(Date.now() - tic, {
domain,
state: 'error',
method: config.method as string,
status: err.response?.status,
});
this._telemetry?.metric('datastore_request', {
domain,
state: 'error',
method: config.method as string,
status: err.response?.status,
});
const _err = this.cloneAxiosError(err);
this._telemetry?.logger?.warn('[sdk.core#request] Error', { err: _err });
this.inspect(_err);
throw _err;
}
}
/**
* @warn this interceptor is here to handle race conditions
* happening in Node.js 20 with `keepAlive` enabled with parallel
* requests.
*
* @see https://github.com/node-fetch/node-fetch/issues/1735
*/
responseInterceptor(error: AxiosError) {
const originalRequest = error.config;
if (originalRequest === undefined) {
throw error;
}
// @ts-ignore
const retry = originalRequest._retry ?? 0;
const originalRequestMethod = originalRequest.method ?? 'get';
if (
retry < this._config.maxRetry &&
this._config.retriableErrors.includes(error.message) &&
this._config.retriableMethods.includes(originalRequestMethod)
) {
if (typeof originalRequest.params?._q === 'string') {
originalRequest.params._q = JSON.parse(originalRequest.params._q);
}
// @ts-ignore
originalRequest._retry = retry + 1;
return this._axios(originalRequest);
}
throw error;
}
}