@unito/integration-sdk 2.3.14 → 2.4.0
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.
- package/dist/src/httpErrors.d.ts +11 -3
- package/dist/src/httpErrors.js +8 -5
- package/dist/src/index.cjs +62 -95
- package/dist/src/index.d.ts +1 -1
- package/dist/src/middlewares/errors.js +3 -0
- package/dist/src/resources/cache.d.ts +9 -64
- package/dist/src/resources/cache.js +20 -90
- package/dist/src/resources/provider.d.ts +0 -1
- package/dist/src/resources/provider.js +31 -0
- package/dist/test/middlewares/errors.test.js +24 -1
- package/dist/test/resources/cache.test.js +2 -3
- package/dist/test/resources/provider.test.js +224 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/httpErrors.ts +8 -5
- package/src/index.ts +1 -1
- package/src/middlewares/errors.ts +4 -0
- package/src/resources/cache.ts +27 -108
- package/src/resources/provider.ts +39 -1
- package/test/middlewares/errors.test.ts +28 -1
- package/test/resources/cache.test.ts +2 -3
- package/test/resources/provider.test.ts +269 -0
|
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { describe, it } from 'node:test';
|
|
3
3
|
import { Readable } from 'stream';
|
|
4
4
|
import nock from 'nock';
|
|
5
|
+
import https from 'https';
|
|
5
6
|
import { Provider } from '../../src/resources/provider.js';
|
|
6
7
|
import * as HttpErrors from '../../src/httpErrors.js';
|
|
7
8
|
import Logger from '../../src/resources/logger.js';
|
|
@@ -23,6 +24,21 @@ describe('Provider', () => {
|
|
|
23
24
|
},
|
|
24
25
|
});
|
|
25
26
|
const logger = new Logger();
|
|
27
|
+
// Helper to spy on https.request and capture options
|
|
28
|
+
const spyOnHttpsRequest = () => {
|
|
29
|
+
let capturedOptions;
|
|
30
|
+
const originalRequest = https.request;
|
|
31
|
+
https.request = function (options, callback) {
|
|
32
|
+
capturedOptions = options;
|
|
33
|
+
return originalRequest.call(https, options, callback);
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
getCapturedOptions: () => capturedOptions,
|
|
37
|
+
restore: () => {
|
|
38
|
+
https.request = originalRequest;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
|
26
42
|
it('get', async (context) => {
|
|
27
43
|
const response = new Response('{"data": "value"}', {
|
|
28
44
|
status: 200,
|
|
@@ -941,4 +957,212 @@ describe('Provider', () => {
|
|
|
941
957
|
unitoCredentialId: '123',
|
|
942
958
|
});
|
|
943
959
|
});
|
|
960
|
+
it('postStream sets timeout to 0 (no timeout)', async () => {
|
|
961
|
+
const streamProvider = new Provider({
|
|
962
|
+
prepareRequest: requestOptions => ({
|
|
963
|
+
url: `https://www.myApi.com`,
|
|
964
|
+
headers: {
|
|
965
|
+
'X-Custom-Provider-Header': 'value',
|
|
966
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
967
|
+
},
|
|
968
|
+
}),
|
|
969
|
+
});
|
|
970
|
+
const testData = 'timeout test data';
|
|
971
|
+
const stream = Readable.from([testData]);
|
|
972
|
+
const spy = spyOnHttpsRequest();
|
|
973
|
+
const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });
|
|
974
|
+
try {
|
|
975
|
+
await streamProvider.postStream('/upload', stream, {
|
|
976
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
977
|
+
logger,
|
|
978
|
+
});
|
|
979
|
+
assert.ok(scope.isDone());
|
|
980
|
+
assert.equal(spy.getCapturedOptions().timeout, 0, 'Timeout should be set to 0 (no timeout)');
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
spy.restore();
|
|
984
|
+
}
|
|
985
|
+
});
|
|
986
|
+
it('postStream handles AbortSignal for request cancellation', async () => {
|
|
987
|
+
const streamProvider = new Provider({
|
|
988
|
+
prepareRequest: requestOptions => ({
|
|
989
|
+
url: `https://www.myApi.com`,
|
|
990
|
+
headers: {
|
|
991
|
+
'X-Custom-Provider-Header': 'value',
|
|
992
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
993
|
+
},
|
|
994
|
+
}),
|
|
995
|
+
});
|
|
996
|
+
const stream = Readable.from(['abort signal test']);
|
|
997
|
+
const abortController = new AbortController();
|
|
998
|
+
// Simulate aborting the request immediately
|
|
999
|
+
abortController.abort();
|
|
1000
|
+
let error;
|
|
1001
|
+
try {
|
|
1002
|
+
await streamProvider.postStream('/upload', stream, {
|
|
1003
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
1004
|
+
logger,
|
|
1005
|
+
signal: abortController.signal,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
catch (e) {
|
|
1009
|
+
error = e;
|
|
1010
|
+
}
|
|
1011
|
+
assert.ok(error instanceof HttpErrors.TimeoutError);
|
|
1012
|
+
assert.equal(error.message, 'Timeout');
|
|
1013
|
+
});
|
|
1014
|
+
it('postStream handles AbortSignal timeout during request', async () => {
|
|
1015
|
+
const streamProvider = new Provider({
|
|
1016
|
+
prepareRequest: requestOptions => ({
|
|
1017
|
+
url: `https://www.myApi.com`,
|
|
1018
|
+
headers: {
|
|
1019
|
+
'X-Custom-Provider-Header': 'value',
|
|
1020
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
1021
|
+
},
|
|
1022
|
+
}),
|
|
1023
|
+
});
|
|
1024
|
+
const stream = Readable.from(['timeout during request test']);
|
|
1025
|
+
const abortController = new AbortController();
|
|
1026
|
+
// Delay response to simulate a slow server
|
|
1027
|
+
nock('https://www.myApi.com').post('/upload').delayConnection(100).reply(201, { success: true });
|
|
1028
|
+
// Abort after 50ms
|
|
1029
|
+
setTimeout(() => abortController.abort(), 50);
|
|
1030
|
+
let error;
|
|
1031
|
+
try {
|
|
1032
|
+
await streamProvider.postStream('/upload', stream, {
|
|
1033
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
1034
|
+
logger,
|
|
1035
|
+
signal: abortController.signal,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
catch (e) {
|
|
1039
|
+
error = e;
|
|
1040
|
+
}
|
|
1041
|
+
assert.ok(error instanceof HttpErrors.TimeoutError);
|
|
1042
|
+
assert.equal(error.message, 'Timeout');
|
|
1043
|
+
});
|
|
1044
|
+
it('postStream cleans up AbortSignal listener on success', async () => {
|
|
1045
|
+
const streamProvider = new Provider({
|
|
1046
|
+
prepareRequest: requestOptions => ({
|
|
1047
|
+
url: `https://www.myApi.com`,
|
|
1048
|
+
headers: {
|
|
1049
|
+
'X-Custom-Provider-Header': 'value',
|
|
1050
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
1051
|
+
},
|
|
1052
|
+
}),
|
|
1053
|
+
});
|
|
1054
|
+
const testData = 'cleanup test';
|
|
1055
|
+
const stream = Readable.from([testData]);
|
|
1056
|
+
const abortController = new AbortController();
|
|
1057
|
+
const scope = nock('https://www.myApi.com').post('/upload', testData).reply(201, { success: true });
|
|
1058
|
+
const response = await streamProvider.postStream('/upload', stream, {
|
|
1059
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
1060
|
+
logger,
|
|
1061
|
+
signal: abortController.signal,
|
|
1062
|
+
});
|
|
1063
|
+
assert.ok(scope.isDone());
|
|
1064
|
+
assert.equal(response.status, 201);
|
|
1065
|
+
// Verify the listener was removed by checking that aborting after completion
|
|
1066
|
+
// doesn't cause any side effects (if listener wasn't removed, this could cause issues)
|
|
1067
|
+
assert.doesNotThrow(() => {
|
|
1068
|
+
abortController.abort();
|
|
1069
|
+
}, 'Aborting after completion should not throw or cause issues');
|
|
1070
|
+
// Verify the signal is indeed aborted
|
|
1071
|
+
assert.ok(abortController.signal.aborted, 'Signal should be aborted');
|
|
1072
|
+
});
|
|
1073
|
+
it('postForm handles AbortSignal for request cancellation', async () => {
|
|
1074
|
+
const FormData = (await import('form-data')).default;
|
|
1075
|
+
const formProvider = new Provider({
|
|
1076
|
+
prepareRequest: requestOptions => ({
|
|
1077
|
+
url: `https://www.myApi.com`,
|
|
1078
|
+
headers: {
|
|
1079
|
+
'X-Custom-Provider-Header': 'value',
|
|
1080
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
1081
|
+
},
|
|
1082
|
+
}),
|
|
1083
|
+
});
|
|
1084
|
+
const form = new FormData();
|
|
1085
|
+
form.append('field1', 'value1');
|
|
1086
|
+
form.append('field2', 'value2');
|
|
1087
|
+
const abortController = new AbortController();
|
|
1088
|
+
// Simulate aborting the request immediately
|
|
1089
|
+
abortController.abort();
|
|
1090
|
+
let error;
|
|
1091
|
+
try {
|
|
1092
|
+
await formProvider.postForm('/upload-form', form, {
|
|
1093
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
1094
|
+
logger,
|
|
1095
|
+
signal: abortController.signal,
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
catch (e) {
|
|
1099
|
+
error = e;
|
|
1100
|
+
}
|
|
1101
|
+
assert.ok(error instanceof HttpErrors.TimeoutError);
|
|
1102
|
+
assert.equal(error.message, 'Timeout');
|
|
1103
|
+
});
|
|
1104
|
+
it('postForm handles AbortSignal timeout during request', async () => {
|
|
1105
|
+
const FormData = (await import('form-data')).default;
|
|
1106
|
+
const formProvider = new Provider({
|
|
1107
|
+
prepareRequest: requestOptions => ({
|
|
1108
|
+
url: `https://www.myApi.com`,
|
|
1109
|
+
headers: {
|
|
1110
|
+
'X-Custom-Provider-Header': 'value',
|
|
1111
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
1112
|
+
},
|
|
1113
|
+
}),
|
|
1114
|
+
});
|
|
1115
|
+
const form = new FormData();
|
|
1116
|
+
form.append('field1', 'value1');
|
|
1117
|
+
const abortController = new AbortController();
|
|
1118
|
+
// Mock a delayed response
|
|
1119
|
+
nock('https://www.myApi.com').post('/upload-form').delayConnection(100).reply(201, { success: true, id: '12345' });
|
|
1120
|
+
// Abort after 50ms
|
|
1121
|
+
setTimeout(() => abortController.abort(), 50);
|
|
1122
|
+
let error;
|
|
1123
|
+
try {
|
|
1124
|
+
await formProvider.postForm('/upload-form', form, {
|
|
1125
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
1126
|
+
logger,
|
|
1127
|
+
signal: abortController.signal,
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
catch (e) {
|
|
1131
|
+
error = e;
|
|
1132
|
+
}
|
|
1133
|
+
assert.ok(error instanceof HttpErrors.TimeoutError);
|
|
1134
|
+
assert.equal(error.message, 'Timeout');
|
|
1135
|
+
});
|
|
1136
|
+
it('postForm successfully completes with AbortSignal provided', async () => {
|
|
1137
|
+
const FormData = (await import('form-data')).default;
|
|
1138
|
+
const formProvider = new Provider({
|
|
1139
|
+
prepareRequest: requestOptions => ({
|
|
1140
|
+
url: `https://www.myApi.com`,
|
|
1141
|
+
headers: {
|
|
1142
|
+
'X-Custom-Provider-Header': 'value',
|
|
1143
|
+
'X-Provider-Credential-Header': requestOptions.credentials.apiKey,
|
|
1144
|
+
},
|
|
1145
|
+
}),
|
|
1146
|
+
});
|
|
1147
|
+
const form = new FormData();
|
|
1148
|
+
form.append('field1', 'value1');
|
|
1149
|
+
form.append('field2', 'value2');
|
|
1150
|
+
const abortController = new AbortController();
|
|
1151
|
+
const scope = nock('https://www.myApi.com').post('/upload-form').reply(201, { success: true, id: '12345' });
|
|
1152
|
+
const response = await formProvider.postForm('/upload-form', form, {
|
|
1153
|
+
credentials: { apiKey: 'apikey#1111', unitoCredentialId: '123' },
|
|
1154
|
+
logger,
|
|
1155
|
+
signal: abortController.signal,
|
|
1156
|
+
});
|
|
1157
|
+
assert.ok(scope.isDone());
|
|
1158
|
+
assert.equal(response.status, 201);
|
|
1159
|
+
assert.deepEqual(response.body, { success: true, id: '12345' });
|
|
1160
|
+
// Verify the listener was removed by checking that aborting after completion
|
|
1161
|
+
// doesn't cause any side effects
|
|
1162
|
+
assert.doesNotThrow(() => {
|
|
1163
|
+
abortController.abort();
|
|
1164
|
+
}, 'Aborting after completion should not throw or cause issues');
|
|
1165
|
+
// Verify the signal is indeed aborted
|
|
1166
|
+
assert.ok(abortController.signal.aborted, 'Signal should be aborted');
|
|
1167
|
+
});
|
|
944
1168
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"root":["../src/errors.ts","../src/handler.ts","../src/helpers.ts","../src/httpErrors.ts","../src/index.ts","../src/integration.ts","../src/middlewares/correlationId.ts","../src/middlewares/credentials.ts","../src/middlewares/errors.ts","../src/middlewares/filters.ts","../src/middlewares/finish.ts","../src/middlewares/health.ts","../src/middlewares/logger.ts","../src/middlewares/notFound.ts","../src/middlewares/relations.ts","../src/middlewares/search.ts","../src/middlewares/secrets.ts","../src/middlewares/selects.ts","../src/middlewares/signal.ts","../src/middlewares/start.ts","../src/resources/cache.ts","../src/resources/context.ts","../src/resources/logger.ts","../src/resources/provider.ts","../test/errors.test.ts","../test/handler.test.ts","../test/helpers.test.ts","../test/integration.test.ts","../test/middlewares/correlationId.test.ts","../test/middlewares/credentials.test.ts","../test/middlewares/errors.test.ts","../test/middlewares/filters.test.ts","../test/middlewares/finish.test.ts","../test/middlewares/health.test.ts","../test/middlewares/logger.test.ts","../test/middlewares/notFound.test.ts","../test/middlewares/relations.test.ts","../test/middlewares/search.test.ts","../test/middlewares/secrets.test.ts","../test/middlewares/selects.test.ts","../test/middlewares/signal.test.ts","../test/middlewares/start.test.ts","../test/resources/cache.test.ts","../test/resources/logger.test.ts","../test/resources/provider.test.ts"],"version":"5.
|
|
1
|
+
{"root":["../src/errors.ts","../src/handler.ts","../src/helpers.ts","../src/httpErrors.ts","../src/index.ts","../src/integration.ts","../src/middlewares/correlationId.ts","../src/middlewares/credentials.ts","../src/middlewares/errors.ts","../src/middlewares/filters.ts","../src/middlewares/finish.ts","../src/middlewares/health.ts","../src/middlewares/logger.ts","../src/middlewares/notFound.ts","../src/middlewares/relations.ts","../src/middlewares/search.ts","../src/middlewares/secrets.ts","../src/middlewares/selects.ts","../src/middlewares/signal.ts","../src/middlewares/start.ts","../src/resources/cache.ts","../src/resources/context.ts","../src/resources/logger.ts","../src/resources/provider.ts","../test/errors.test.ts","../test/handler.test.ts","../test/helpers.test.ts","../test/integration.test.ts","../test/middlewares/correlationId.test.ts","../test/middlewares/credentials.test.ts","../test/middlewares/errors.test.ts","../test/middlewares/filters.test.ts","../test/middlewares/finish.test.ts","../test/middlewares/health.test.ts","../test/middlewares/logger.test.ts","../test/middlewares/notFound.test.ts","../test/middlewares/relations.test.ts","../test/middlewares/search.test.ts","../test/middlewares/secrets.test.ts","../test/middlewares/selects.test.ts","../test/middlewares/signal.test.ts","../test/middlewares/start.test.ts","../test/resources/cache.test.ts","../test/resources/logger.test.ts","../test/resources/provider.test.ts"],"version":"5.9.3"}
|
package/package.json
CHANGED
package/src/httpErrors.ts
CHANGED
|
@@ -4,14 +4,17 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @field message - The error message
|
|
6
6
|
* @field status - The HTTP status code to return
|
|
7
|
+
* @field retryAfter - The minimum number of seconds to wait before retrying the request.
|
|
7
8
|
*/
|
|
8
9
|
export class HttpError extends Error {
|
|
9
10
|
readonly status: number;
|
|
11
|
+
readonly retryAfter: number | undefined = undefined;
|
|
10
12
|
|
|
11
|
-
constructor(message: string, status: number) {
|
|
13
|
+
constructor(message: string, status: number, options: { retryAfter?: number } = {}) {
|
|
12
14
|
super(message);
|
|
13
15
|
this.status = status;
|
|
14
16
|
this.name = this.constructor.name;
|
|
17
|
+
this.retryAfter = options.retryAfter;
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
|
|
@@ -84,8 +87,8 @@ export class UnprocessableEntityError extends HttpError {
|
|
|
84
87
|
* Used to generate a 423 Provider Instance Locked.
|
|
85
88
|
*/
|
|
86
89
|
export class ProviderInstanceLockedError extends HttpError {
|
|
87
|
-
constructor(message?: string) {
|
|
88
|
-
super(message || 'Provider instance locked or unavailable', 423);
|
|
90
|
+
constructor(message?: string, options: { retryAfter?: number } = {}) {
|
|
91
|
+
super(message || 'Provider instance locked or unavailable', 423, options);
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
|
|
@@ -94,7 +97,7 @@ export class ProviderInstanceLockedError extends HttpError {
|
|
|
94
97
|
* error on the provider's side.
|
|
95
98
|
*/
|
|
96
99
|
export class RateLimitExceededError extends HttpError {
|
|
97
|
-
constructor(message?: string) {
|
|
98
|
-
super(message || 'Rate Limit Exceeded', 429);
|
|
100
|
+
constructor(message?: string, options: { retryAfter?: number } = {}) {
|
|
101
|
+
super(message || 'Rate Limit Exceeded', 429, options);
|
|
99
102
|
}
|
|
100
103
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* c8 ignore start */
|
|
2
2
|
export * as Api from '@unito/integration-api';
|
|
3
3
|
|
|
4
|
-
export { Cache } from './resources/cache.js';
|
|
4
|
+
export { Cache, type CacheInstance } from './resources/cache.js';
|
|
5
5
|
export { default as Integration } from './integration.js';
|
|
6
6
|
export * from './handler.js';
|
|
7
7
|
export {
|
|
@@ -20,6 +20,10 @@ function onError(err: Error, _req: Request, res: Response, next: NextFunction) {
|
|
|
20
20
|
let error: ApiError;
|
|
21
21
|
|
|
22
22
|
if (err instanceof HttpError) {
|
|
23
|
+
if (err.retryAfter) {
|
|
24
|
+
res.setHeader('Retry-After', String(err.retryAfter));
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
error = {
|
|
24
28
|
code: err.status.toString(),
|
|
25
29
|
message: err.message,
|
package/src/resources/cache.ts
CHANGED
|
@@ -1,115 +1,34 @@
|
|
|
1
|
-
import { LocalCache, CacheInstance,
|
|
1
|
+
import { LocalCache, CacheInstance, RedisCache } from 'cachette';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
import Logger from './logger.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* It can be backed by a Redis instance (by passing it a URL to the instance) or a local cache.
|
|
6
|
+
* Initializes a Cache backed by the Redis instance at the provided url if present, or a LocalCache otherwise.
|
|
8
7
|
*
|
|
9
|
-
* @
|
|
8
|
+
* @param redisUrl - The redis url to connect to (optional).
|
|
9
|
+
* @returns A cache instance.
|
|
10
10
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
public getOrFetchValue<F extends FetchingFunction = FetchingFunction>(
|
|
32
|
-
key: string,
|
|
33
|
-
ttl: number,
|
|
34
|
-
fetcher: F,
|
|
35
|
-
lockTtl?: number,
|
|
36
|
-
shouldCacheError?: (err: Error) => boolean,
|
|
37
|
-
): Promise<ReturnType<F>> {
|
|
38
|
-
return this.cacheInstance.getOrFetchValue(key, ttl, fetcher, lockTtl, shouldCacheError);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Get a value from the cache.
|
|
43
|
-
*
|
|
44
|
-
* @param key The key of the value to get.
|
|
45
|
-
*
|
|
46
|
-
* @return The value associated with the key, or undefined if
|
|
47
|
-
* no such value exists.
|
|
48
|
-
*/
|
|
49
|
-
public getValue(key: string): Promise<CachableValue> {
|
|
50
|
-
return this.cacheInstance.getValue(key);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Set a value in the cache.
|
|
55
|
-
*
|
|
56
|
-
* @param key The key of the value to set.
|
|
57
|
-
* @param value The value to set.
|
|
58
|
-
* @param ttl The time to live of the value in seconds.
|
|
59
|
-
* By default, the value will not expire
|
|
60
|
-
*
|
|
61
|
-
* @return true if the value was stored, false otherwise.
|
|
62
|
-
*/
|
|
63
|
-
public setValue(key: string, value: CachableValue, ttl?: number): Promise<boolean> {
|
|
64
|
-
return this.cacheInstance.setValue(key, value, ttl);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Delete a value from the cache.
|
|
69
|
-
* @param key — The key of the value to set.
|
|
70
|
-
*/
|
|
71
|
-
public delValue(key: string): Promise<void> {
|
|
72
|
-
return this.cacheInstance.delValue(key);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Get the TTL of an entry, in ms
|
|
77
|
-
*
|
|
78
|
-
* @param key The key of the entry whose ttl to retrieve
|
|
79
|
-
*
|
|
80
|
-
* @return The remaining TTL on the entry, in ms.
|
|
81
|
-
* undefined if the entry does not exist.
|
|
82
|
-
* 0 if the entry does not expire.
|
|
83
|
-
*/
|
|
84
|
-
public getTtl(key: string): Promise<number | undefined> {
|
|
85
|
-
return this.cacheInstance.getTtl(key);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Initializes a Cache backed by the Redis instance at the provided url if present, or a LocalCache otherwise.
|
|
90
|
-
*
|
|
91
|
-
* @param redisUrl - The redis url to connect to (optional).
|
|
92
|
-
* @returns A cache instance.
|
|
93
|
-
*/
|
|
94
|
-
public static create(redisUrl?: string): Cache {
|
|
95
|
-
const cacheInstance: CacheInstance = redisUrl ? new RedisCache(redisUrl) : new LocalCache();
|
|
96
|
-
|
|
97
|
-
// Intended: the correlation id will be the same for all logs of Cachette.
|
|
98
|
-
const correlationId = crypto.randomUUID();
|
|
99
|
-
|
|
100
|
-
const logger = new Logger({ correlation_id: correlationId });
|
|
101
|
-
|
|
102
|
-
cacheInstance
|
|
103
|
-
.on('info', message => {
|
|
104
|
-
logger.info(message);
|
|
105
|
-
})
|
|
106
|
-
.on('warn', message => {
|
|
107
|
-
logger.warn(message);
|
|
108
|
-
})
|
|
109
|
-
.on('error', message => {
|
|
110
|
-
logger.error(message);
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
return new Cache(cacheInstance);
|
|
114
|
-
}
|
|
11
|
+
function create(redisUrl?: string): CacheInstance {
|
|
12
|
+
const cacheInstance: CacheInstance = redisUrl ? new RedisCache(redisUrl) : new LocalCache();
|
|
13
|
+
|
|
14
|
+
// Intended: the correlation id will be the same for all logs of Cachette.
|
|
15
|
+
const correlationId = crypto.randomUUID();
|
|
16
|
+
|
|
17
|
+
const logger = new Logger({ correlation_id: correlationId });
|
|
18
|
+
|
|
19
|
+
cacheInstance
|
|
20
|
+
.on('info', message => {
|
|
21
|
+
logger.info(message);
|
|
22
|
+
})
|
|
23
|
+
.on('warn', message => {
|
|
24
|
+
logger.warn(message);
|
|
25
|
+
})
|
|
26
|
+
.on('error', message => {
|
|
27
|
+
logger.error(message);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return cacheInstance;
|
|
115
31
|
}
|
|
32
|
+
|
|
33
|
+
export const Cache = { create };
|
|
34
|
+
export type { CacheInstance };
|
|
@@ -21,7 +21,6 @@ import Logger from '../resources/logger.js';
|
|
|
21
21
|
* @param targetFunction - The function to call the provider.
|
|
22
22
|
* @returns The response from the provider.
|
|
23
23
|
* @throws RateLimitExceededError when the rate limit is exceeded.
|
|
24
|
-
* @throws WouldExceedRateLimitError when the next call would exceed the rate limit.
|
|
25
24
|
* @throws HttpError when the provider returns an error.
|
|
26
25
|
*/
|
|
27
26
|
export type RateLimiter = <T>(
|
|
@@ -243,6 +242,25 @@ export class Provider {
|
|
|
243
242
|
reject(this.handleError(400, `Error while calling the provider: "${error}"`, options));
|
|
244
243
|
});
|
|
245
244
|
|
|
245
|
+
if (options.signal) {
|
|
246
|
+
const abortHandler = () => {
|
|
247
|
+
request.destroy();
|
|
248
|
+
reject(this.handleError(408, 'Timeout', options));
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (options.signal.aborted) {
|
|
252
|
+
abortHandler();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
options.signal.addEventListener('abort', abortHandler);
|
|
256
|
+
|
|
257
|
+
request.on('close', () => {
|
|
258
|
+
if (options.signal) {
|
|
259
|
+
options.signal.removeEventListener('abort', abortHandler);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
246
264
|
form.pipe(request);
|
|
247
265
|
} catch (error) {
|
|
248
266
|
reject(this.handleError(500, `Unexpected error while calling the provider: "${error}"`, options));
|
|
@@ -303,6 +321,7 @@ export class Provider {
|
|
|
303
321
|
path: urlObj.pathname + urlObj.search,
|
|
304
322
|
method: 'POST',
|
|
305
323
|
headers,
|
|
324
|
+
timeout: 0,
|
|
306
325
|
};
|
|
307
326
|
|
|
308
327
|
const request = https.request(requestOptions, response => {
|
|
@@ -350,6 +369,25 @@ export class Provider {
|
|
|
350
369
|
safeReject(this.handleError(500, `Stream error: "${error}"`, options));
|
|
351
370
|
});
|
|
352
371
|
|
|
372
|
+
if (options.signal) {
|
|
373
|
+
const abortHandler = () => {
|
|
374
|
+
request.destroy();
|
|
375
|
+
safeReject(this.handleError(408, 'Timeout', options));
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
if (options.signal.aborted) {
|
|
379
|
+
abortHandler();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
options.signal.addEventListener('abort', abortHandler);
|
|
383
|
+
|
|
384
|
+
request.on('close', () => {
|
|
385
|
+
if (options.signal) {
|
|
386
|
+
options.signal.removeEventListener('abort', abortHandler);
|
|
387
|
+
}
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
353
391
|
// Stream the data directly without buffering
|
|
354
392
|
stream.pipe(request);
|
|
355
393
|
} catch (error) {
|
|
@@ -2,7 +2,7 @@ import express from 'express';
|
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { describe, it } from 'node:test';
|
|
4
4
|
import onError from '../../src/middlewares/errors.js';
|
|
5
|
-
import { HttpError } from '../../src/httpErrors.js';
|
|
5
|
+
import { HttpError, RateLimitExceededError } from '../../src/httpErrors.js';
|
|
6
6
|
|
|
7
7
|
describe('errors middleware', () => {
|
|
8
8
|
it('headers sent, do nothing', () => {
|
|
@@ -52,6 +52,33 @@ describe('errors middleware', () => {
|
|
|
52
52
|
assert.deepEqual(actualJson, { code: '429', message: 'httpError' });
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
it('handles retry-after header', () => {
|
|
56
|
+
let actualStatus: number | undefined;
|
|
57
|
+
let actualJson: Record<string, unknown> | undefined;
|
|
58
|
+
|
|
59
|
+
const response = {
|
|
60
|
+
headersSent: false,
|
|
61
|
+
status: (code: number) => {
|
|
62
|
+
actualStatus = code;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
json: (json: Record<string, unknown>) => {
|
|
66
|
+
actualJson = json;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
locals: { logger: { error: () => undefined } },
|
|
71
|
+
setHeader: (name: string, value: string) => {
|
|
72
|
+
assert.strictEqual(name, 'Retry-After');
|
|
73
|
+
assert.strictEqual(value, '1234');
|
|
74
|
+
},
|
|
75
|
+
} as unknown as express.Response;
|
|
76
|
+
onError(new RateLimitExceededError('httpError', { retryAfter: 1234 }), {} as express.Request, response, () => {});
|
|
77
|
+
|
|
78
|
+
assert.strictEqual(actualStatus, 429);
|
|
79
|
+
assert.deepEqual(actualJson, { code: '429', message: 'httpError' });
|
|
80
|
+
});
|
|
81
|
+
|
|
55
82
|
it('handles other error', () => {
|
|
56
83
|
let actualStatus: number | undefined;
|
|
57
84
|
let actualJson: Record<string, unknown> | undefined;
|
|
@@ -8,10 +8,9 @@ describe('Cache', () => {
|
|
|
8
8
|
it('no redis url returns Cache with a inner LocalCache', async () => {
|
|
9
9
|
const cache = Cache.create();
|
|
10
10
|
|
|
11
|
-
assert.ok(cache instanceof
|
|
12
|
-
assert.ok(cache['cacheInstance'] instanceof LocalCache);
|
|
11
|
+
assert.ok(cache instanceof LocalCache);
|
|
13
12
|
|
|
14
|
-
await cache
|
|
13
|
+
await cache.quit();
|
|
15
14
|
});
|
|
16
15
|
|
|
17
16
|
it('redis url tries to return a RedisCache', () => {
|