UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

224 lines (188 loc) 5.84 kB
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; } }