@rabbitio/ui-kit 1.0.0-beta.37 → 1.0.0-beta.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,188 @@
1
+ import Hashes from "jshashes";
2
+
3
+ import { improveAndRethrow } from "../common/errorUtils.js";
4
+ import { safeStringify } from "../common/utils/safeStringify.js";
5
+ import { Logger } from "../common/utils/logging/logger.js";
6
+
7
+ import { RobustExternalAPICallerService } from "./robustExternalAPICallerService.js";
8
+ import { CacheAndConcurrentRequestsResolver } from "./cacheAndConcurrentRequestsResolver.js";
9
+
10
+ /**
11
+ * Extended edit of RobustExternalApiCallerService supporting cache and management of concurrent requests
12
+ * to the same resource.
13
+ * TODO: [tests, critical] Massively used logic
14
+ */
15
+ export class CachedRobustExternalApiCallerService {
16
+ /**
17
+ * @param bio {string} unique service identifier
18
+ * @param cache {Cache} cache instance
19
+ * @param providersData {ExternalApiProvider[]} array of providers
20
+ * @param [cacheTtlMs=10000] {number} time to live for cache ms
21
+ * @param [maxCallAttemptsToWaitForAlreadyRunningRequest=50] {number} see details in CacheAndConcurrentRequestsResolver
22
+ * @param [timeoutBetweenAttemptsToCheckWhetherAlreadyRunningRequestFinished=3000] {number} see details in CacheAndConcurrentRequestsResolver
23
+ * @param [removeExpiredCacheAutomatically=true] {boolean} whether to remove cached data automatically when ttl exceeds
24
+ * @param [mergeCachedAndNewlyRetrievedData=null] {function} function accepting cached data, newly retrieved data and id field name for list items
25
+ * and merging them. use if needed
26
+ */
27
+ constructor(
28
+ bio,
29
+ cache,
30
+ providersData,
31
+ cacheTtlMs = 10000,
32
+ removeExpiredCacheAutomatically = true,
33
+ mergeCachedAndNewlyRetrievedData = null,
34
+ maxCallAttemptsToWaitForAlreadyRunningRequest = 100,
35
+ timeoutBetweenAttemptsToCheckWhetherAlreadyRunningRequestFinished = 1000
36
+ ) {
37
+ this._provider = new RobustExternalAPICallerService(
38
+ `cached_${bio}`,
39
+ providersData,
40
+ Logger.logError
41
+ );
42
+ this._cacheTtlMs = cacheTtlMs;
43
+ this._cahceAndRequestsResolver = new CacheAndConcurrentRequestsResolver(
44
+ bio,
45
+ cache,
46
+ cacheTtlMs,
47
+ removeExpiredCacheAutomatically,
48
+ maxCallAttemptsToWaitForAlreadyRunningRequest,
49
+ timeoutBetweenAttemptsToCheckWhetherAlreadyRunningRequestFinished
50
+ );
51
+ this._cahceIds = [];
52
+ this._mergeCachedAndNewlyRetrievedData =
53
+ mergeCachedAndNewlyRetrievedData;
54
+ }
55
+
56
+ /**
57
+ * Calls the external API or returns data from cache. Just waits if the same data already requested.
58
+ *
59
+ * @param parametersValues {array} array of values of the parameters for URL query string [and/or body]
60
+ * @param timeoutMS {number} http timeout to wait for response. If provider has its specific timeout value then it is used
61
+ * @param [cancelToken] {object|undefined} axios token to force-cancel requests from high-level code
62
+ * @param [attemptsCount] {number|undefined} number of attempts to be performed
63
+ * @param [customHashFunctionForParams] {function|undefined} function without params calculating the hash to be
64
+ * added to bio of the service to compose a unique parameters-specific cache id
65
+ * @param [doNotFailForNowData] {boolean|undefined} pass true if you do not want us to throw an error if we retrieved null data from all the providers
66
+ * @return {Promise<any>} resolving to retrieved data (or array of results if specific provider requires
67
+ * several requests. NOTE: we flatten nested arrays - results of each separate request done for the specific provider)
68
+ * @throws Error if requests to all providers are failed
69
+ */
70
+ async callExternalAPICached(
71
+ parametersValues = [],
72
+ timeoutMS = 3500,
73
+ cancelToken = null,
74
+ attemptsCount = 1,
75
+ customHashFunctionForParams = null,
76
+ doNotFailForNowData = false
77
+ ) {
78
+ const loggerSource = `${this._provider.bio}.callExternalAPICached`;
79
+ let cacheId;
80
+ let result;
81
+ try {
82
+ cacheId = this._calculateCacheId(
83
+ parametersValues,
84
+ customHashFunctionForParams
85
+ );
86
+ result =
87
+ await this._cahceAndRequestsResolver.getCachedOrWaitForCachedOrAcquireLock(
88
+ cacheId
89
+ );
90
+ if (!result?.canStartDataRetrieval) {
91
+ return result?.cachedData;
92
+ }
93
+
94
+ let data = await this._provider.callExternalAPI(
95
+ parametersValues,
96
+ timeoutMS,
97
+ cancelToken,
98
+ attemptsCount,
99
+ doNotFailForNowData
100
+ );
101
+
102
+ const canPerformMerge =
103
+ typeof this._mergeCachedAndNewlyRetrievedData === "function";
104
+ if (canPerformMerge) {
105
+ const mostRecentCached =
106
+ this._cahceAndRequestsResolver.getCached(cacheId);
107
+ data = this._mergeCachedAndNewlyRetrievedData(
108
+ mostRecentCached,
109
+ data,
110
+ parametersValues
111
+ );
112
+ }
113
+ if (data != null) {
114
+ this._cahceAndRequestsResolver.saveCachedData(
115
+ cacheId,
116
+ result?.lockId,
117
+ data,
118
+ true,
119
+ canPerformMerge
120
+ );
121
+ this._cahceIds.indexOf(cacheId) < 0 &&
122
+ this._cahceIds.push(cacheId);
123
+ }
124
+
125
+ return data;
126
+ } catch (e) {
127
+ improveAndRethrow(e, loggerSource);
128
+ } finally {
129
+ this._cahceAndRequestsResolver.releaseLock(cacheId, result?.lockId);
130
+ }
131
+ }
132
+
133
+ invalidateCaches() {
134
+ this._cahceIds.forEach((key) =>
135
+ this._cahceAndRequestsResolver.invalidate(key)
136
+ );
137
+ }
138
+
139
+ actualizeCachedData(
140
+ params,
141
+ synchronousCurrentCacheProcessor,
142
+ customHashFunctionForParams = null,
143
+ sessionDependent = true,
144
+ actualizedAtTimestamp
145
+ ) {
146
+ const cacheId = this._calculateCacheId(
147
+ params,
148
+ customHashFunctionForParams
149
+ );
150
+ this._cahceAndRequestsResolver.actualizeCachedData(
151
+ cacheId,
152
+ synchronousCurrentCacheProcessor,
153
+ sessionDependent
154
+ );
155
+ }
156
+
157
+ markCacheAsExpiredButDontRemove(
158
+ parametersValues,
159
+ customHashFunctionForParams
160
+ ) {
161
+ try {
162
+ this._cahceAndRequestsResolver.markAsExpiredButDontRemove(
163
+ this._calculateCacheId(
164
+ parametersValues,
165
+ customHashFunctionForParams
166
+ )
167
+ );
168
+ } catch (e) {
169
+ improveAndRethrow(e, "markCacheAsExpiredButDontRemove");
170
+ }
171
+ }
172
+
173
+ _calculateCacheId(parametersValues, customHashFunctionForParams = null) {
174
+ try {
175
+ const hash =
176
+ typeof customHashFunctionForParams === "function"
177
+ ? customHashFunctionForParams(parametersValues)
178
+ : !parametersValues
179
+ ? ""
180
+ : new Hashes.SHA512().hex(
181
+ safeStringify(parametersValues)
182
+ );
183
+ return `${this._provider.bio}-${hash}`;
184
+ } catch (e) {
185
+ improveAndRethrow(e, this._provider.bio + "_calculateCacheId");
186
+ }
187
+ }
188
+ }
@@ -0,0 +1,29 @@
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * Utils class needed to perform cancelling of axios request inside some process.
5
+ * Provides cancel state and axios token for HTTP requests
6
+ */
7
+ export class CancelProcessing {
8
+ constructor() {
9
+ this._cancelToken = axios.CancelToken.source();
10
+ this._isCanceled = false;
11
+ }
12
+
13
+ cancel() {
14
+ this._isCanceled = true;
15
+ this._cancelToken.cancel();
16
+ }
17
+
18
+ isCanceled() {
19
+ return this._isCanceled;
20
+ }
21
+
22
+ getToken() {
23
+ return this._cancelToken.token;
24
+ }
25
+
26
+ static instance() {
27
+ return new CancelProcessing();
28
+ }
29
+ }
@@ -0,0 +1,103 @@
1
+ import { v4 } from "uuid";
2
+
3
+ import { Logger } from "../common/utils/logging/logger.js";
4
+
5
+ // TODO: [refactoring, low] Consider removing this logic task_id=c360f2af75764bde8badd9ff1cc00d48
6
+ class ConcurrentCalculationsMetadataHolder {
7
+ constructor() {
8
+ this._calculations = {};
9
+ }
10
+
11
+ startCalculation(domain, calculationsHistoryMaxLength = 100) {
12
+ if (!this._calculations[domain]) {
13
+ this._calculations[domain] = [];
14
+ }
15
+
16
+ if (this._calculations[domain].length > calculationsHistoryMaxLength) {
17
+ this._calculations[domain] = this._calculations[domain].slice(
18
+ Math.round(calculationsHistoryMaxLength * 0.2)
19
+ );
20
+ }
21
+
22
+ const newCalculation = {
23
+ startTimestamp: Date.now(),
24
+ endTimestamp: null,
25
+ uuid: v4(),
26
+ };
27
+
28
+ this._calculations[domain].push(newCalculation);
29
+
30
+ return newCalculation.uuid;
31
+ }
32
+
33
+ endCalculation(domain, uuid, isFailed = false) {
34
+ try {
35
+ const calculation = this._calculations[domain].find(
36
+ (calculation) => calculation?.uuid === uuid
37
+ );
38
+ if (calculation) {
39
+ calculation.endTimestamp = Date.now();
40
+ calculation.isFiled = isFailed;
41
+ }
42
+
43
+ const elapsed = (
44
+ ((calculation?.endTimestamp ?? 0) -
45
+ (calculation?.startTimestamp ?? 0)) /
46
+ 1000
47
+ ).toFixed(1);
48
+ Logger.log(
49
+ "endCalculation",
50
+ `${elapsed} ms: ${domain}.${(calculation?.uuid ?? "").slice(
51
+ 0,
52
+ 7
53
+ )}`
54
+ );
55
+
56
+ return calculation;
57
+ } catch (e) {
58
+ Logger.logError(e, "endCalculation");
59
+ }
60
+ }
61
+
62
+ isCalculationLate(domain, uuid) {
63
+ const queue = this._calculations[domain];
64
+ const analysingCalculation = queue.find((item) => item.uuid === uuid);
65
+ return (
66
+ analysingCalculation &&
67
+ !!queue.find(
68
+ (calculation) =>
69
+ calculation.endTimestamp != null &&
70
+ calculation.startTimestamp >
71
+ analysingCalculation.startTimestamp
72
+ )
73
+ );
74
+ }
75
+
76
+ printCalculationsWaitingMoreThanSpecifiedSeconds(waitingLastsMs = 2000) {
77
+ const calculations = Object.keys(this._calculations)
78
+ .map((domain) =>
79
+ this._calculations[domain].map((c) => ({ ...c, domain }))
80
+ )
81
+ .flat()
82
+ .filter(
83
+ (c) =>
84
+ c.endTimestamp === null &&
85
+ Date.now() - c.startTimestamp > waitingLastsMs
86
+ );
87
+ Logger.log(
88
+ "printCalculationsWaitingMoreThanSpecifiedSeconds",
89
+ `Calculations waiting more than ${(waitingLastsMs / 1000).toFixed(
90
+ 1
91
+ )}s:\n` +
92
+ calculations.map(
93
+ (c) =>
94
+ `${c.domain}.${c.uuid.slice(0, 8)}: ${
95
+ Date.now() - c.startTimestamp
96
+ }\n`
97
+ )
98
+ );
99
+ }
100
+ }
101
+
102
+ export const concurrentCalculationsMetadataHolder =
103
+ new ConcurrentCalculationsMetadataHolder();
@@ -0,0 +1,156 @@
1
+ export class ExternalApiProvider {
2
+ /**
3
+ * Creates an instance of external api provider.
4
+ *
5
+ * If you need sub-request then use 'subRequestIndex' to check current request index in functions below.
6
+ * Also use array for 'httpMethod'.
7
+ *
8
+ * If the endpoint of dedicated provider has pagination then you should customize the behavior using
9
+ * "changeQueryParametersForPageNumber", "checkWhetherResponseIsForLastPage".
10
+ *
11
+ * We perform RPS counting all over the App to avoid blocking our clients due to abuses of the providers.
12
+ *
13
+ * @param endpoint {string} URL to the provider's endpoint. Note: you can customize it using composeQueryString
14
+ * @param [httpMethod] {string|string[]} one of "get", "post", "put", "patch", "delete" or an array of these values
15
+ * for request having sub-requests
16
+ * @param [timeout] {number} number of milliseconds to wait for the response
17
+ * @param [apiGroup] {ApiGroup} singleton object containing parameters of API group. Helpful when you use the same
18
+ * api for different providers to avoid hardcoding RPS inside each provider what can cause mistakes
19
+ * @param [specificHeaders] {Object} contains specific keys (headers) and values (their content) if needed for this provider
20
+ * @param [maxPageLength] {number} optional number of items per page if the request supports pagination
21
+ */
22
+ constructor(
23
+ endpoint,
24
+ httpMethod,
25
+ timeout,
26
+ apiGroup,
27
+ specificHeaders = {},
28
+ maxPageLength = Number.MAX_SAFE_INTEGER
29
+ ) {
30
+ this.endpoint = endpoint;
31
+ this.httpMethod = httpMethod ?? "get";
32
+ // TODO: [refactoring, critical] We have two timeouts for robust data retrieval - here and inside the service method call, need to remain the only
33
+ this.timeout = timeout ?? 10000;
34
+ // TODO: [refactoring, critical] We need single place for all RPSes as we use them as hardcoded constants now inside different services
35
+ this.apiGroup = apiGroup;
36
+ this.maxPageLength = maxPageLength ?? Number.MAX_SAFE_INTEGER;
37
+ this.niceFactor = 1;
38
+ this.specificHeaders = specificHeaders ?? {};
39
+ }
40
+
41
+ getRps() {
42
+ return this.apiGroup.rps ?? 2;
43
+ }
44
+
45
+ isRpsExceeded() {
46
+ return this.apiGroup.isRpsExceeded();
47
+ }
48
+
49
+ actualizeLastCalledTimestamp() {
50
+ this.apiGroup.actualizeLastCalledTimestamp();
51
+ }
52
+
53
+ getApiGroupId() {
54
+ return this.apiGroup.id;
55
+ }
56
+
57
+ /**
58
+ * Some endpoint can require several sub requests. Example is one request to get confirmed transactions
59
+ * and another request for unconfirmed transactions. You should override this method to return true for such requests.
60
+ *
61
+ * @return {boolean} true if this provider requires several requests to retrieve the data
62
+ */
63
+ doesRequireSubRequests() {
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * Some endpoint support pagination. Override this method if so and implement corresponding methods.
69
+ *
70
+ * @return {boolean} true if this provider requires several requests to retrieve the data
71
+ */
72
+ doesSupportPagination() {
73
+ return false;
74
+ }
75
+
76
+ /**
77
+ * Composes a query string to be added to the endpoint of this provider.
78
+ *
79
+ * @param params {any[]} params array passed to the RobustExternalAPICallerService
80
+ * @param [subRequestIndex] {number} optional number of the sub-request the call is performed for
81
+ * @returns {string} query string to be concatenated with endpoint
82
+ */
83
+ composeQueryString(params, subRequestIndex = 0) {
84
+ return "";
85
+ }
86
+
87
+ /**
88
+ * Composes a body to be added to the request
89
+ *
90
+ * @param params {any[]} params array passed to the RobustExternalAPICallerService
91
+ * @param [subRequestIndex] {number} optional number of the sub-request the call is performed for
92
+ * @returns {string}
93
+ */
94
+ composeBody(params, subRequestIndex = 0) {
95
+ return "";
96
+ }
97
+
98
+ /**
99
+ * Extracts data from the response and returns it
100
+ *
101
+ * @param response {Object} HTTP response returned by provider
102
+ * @param [params] {any[]} params array passed to the RobustExternalAPICallerService
103
+ * @param [subRequestIndex] {number} optional number of the sub-request the call is performed for
104
+ * @param iterationsData {any[]} array of data retrieved from previous sub-requests
105
+ * @returns {any}
106
+ */
107
+ getDataByResponse(response, params = [], subRequestIndex = 0, iterationsData = []) {
108
+ return [];
109
+ }
110
+
111
+ /**
112
+ * Function changing the query string according to page number and previous response
113
+ * Only for endpoints supporting pagination
114
+ *
115
+ * @param params {any[]} params array passed to the RobustExternalAPICallerService
116
+ * @param previousResponse {Object} HTTP response returned by provider for previous call (previous page)
117
+ * @param pageNumber {number} new page number. We count from 0. You need to manually increment with 1 if your
118
+ * provider counts pages starting with 1
119
+ * @param [subRequestIndex] {number} optional number of the sub-request the call is performed for
120
+ * @returns {any[]}
121
+ */
122
+ changeQueryParametersForPageNumber(params, previousResponse, pageNumber, subRequestIndex = 0) {
123
+ return params;
124
+ }
125
+
126
+ /**
127
+ * Function checking whether the response is for the last page to stop requesting for a next page.
128
+ * Only for endpoints supporting pagination.
129
+ *
130
+ * @param previousResponse {Object} HTTP response returned by provider for previous call (previous page)
131
+ * @param currentResponse {Object} HTTP response returned by provider for current call (current page, next after the previous)
132
+ * @param currentPageNumber {number} current page number (for current response)
133
+ * @param [subRequestIndex] {number} optional number of the sub-request the call is performed for
134
+ * @returns {boolean}
135
+ */
136
+ checkWhetherResponseIsForLastPage(previousResponse, currentResponse, currentPageNumber, subRequestIndex = 0) {
137
+ return true;
138
+ }
139
+
140
+ /**
141
+ * Resets the nice factor to default value
142
+ */
143
+ resetNiceFactor() {
144
+ this.niceFactor = 1;
145
+ }
146
+
147
+ /**
148
+ * Internal method used for requests requiring sub-requests.
149
+ *
150
+ * @param iterationsData {any[]} iterations data retrieved from getDataByResponse called per sub-request.
151
+ * @return {any} by default flatten the passed iterations data array. Should be redefined if you need another logic.
152
+ */
153
+ incorporateIterationsData(iterationsData) {
154
+ return iterationsData.flat();
155
+ }
156
+ }
@@ -0,0 +1,82 @@
1
+ import { improveAndRethrow } from "../common/errorUtils.js";
2
+ import { Logger } from "../common/utils/logging/logger.js";
3
+
4
+ export class ExternalServicesStatsCollector {
5
+ constructor() {
6
+ this.stats = new Map();
7
+ }
8
+
9
+ externalServiceFailed(serviceUrl, message) {
10
+ try {
11
+ const processMessage = (stat, errorMessage) => {
12
+ const errors = stat.errors ?? {};
13
+ errorMessage = errorMessage ?? "";
14
+ if (errorMessage.match(/.*network.+error.*/i)) {
15
+ errors["networkError"] = (errors["networkError"] || 0) + 1;
16
+ } else if (errorMessage.match(/.*timeout.+exceeded.*/i)) {
17
+ errors["timeoutExceeded"] =
18
+ (errors["timeoutExceeded"] || 0) + 1;
19
+ } else if (errors["other"]) {
20
+ errors["other"].push(message);
21
+ } else {
22
+ errors["other"] = [message];
23
+ }
24
+
25
+ stat.errors = errors;
26
+ };
27
+
28
+ if (this.stats.has(serviceUrl)) {
29
+ const stat = this.stats.get(serviceUrl);
30
+ stat.callsCount += 1;
31
+ stat.failsCount += 1;
32
+ processMessage(stat, message);
33
+ } else {
34
+ this.stats.set(serviceUrl, { callsCount: 1, failsCount: 1 });
35
+ processMessage(this.stats.get(serviceUrl), message);
36
+ }
37
+ } catch (e) {
38
+ improveAndRethrow(e, "externalServiceFailed");
39
+ }
40
+ }
41
+
42
+ externalServiceCalledWithoutError(serviceUrl) {
43
+ try {
44
+ if (this.stats.has(serviceUrl)) {
45
+ const stat = this.stats.get(serviceUrl);
46
+ stat.callsCount += 1;
47
+ } else {
48
+ this.stats.set(serviceUrl, { callsCount: 1, failsCount: 0 });
49
+ }
50
+ } catch (e) {
51
+ improveAndRethrow(e, "externalServiceCalledWithoutError");
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Returns statistics about external services failures.
57
+ * Provides how many calls were performed and what the percent of failed calls. Also returns errors stat.
58
+ *
59
+ * @return {Array<object>} Array of objects of type { failsPerCent: number, calls: number }
60
+ * sorted by the highest fails percent desc
61
+ */
62
+ getStats() {
63
+ try {
64
+ return Array.from(this.stats.keys())
65
+ .map((key) => {
66
+ const stat = this.stats.get(key);
67
+ return {
68
+ url: key,
69
+ failsPerCent: (
70
+ (stat.failsCount / stat.callsCount) *
71
+ 100
72
+ ).toFixed(2),
73
+ calls: stat.callsCount,
74
+ errors: stat.errors ?? [],
75
+ };
76
+ })
77
+ .sort((s1, s2) => s1.failsPerCent - s2.failsPerCent);
78
+ } catch (e) {
79
+ Logger.logError(e, "getStats");
80
+ }
81
+ }
82
+ }