@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.
@@ -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.8.3"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unito/integration-sdk",
3
- "version": "2.3.14",
3
+ "version": "2.4.0",
4
4
  "description": "Integration SDK",
5
5
  "type": "module",
6
6
  "types": "dist/src/index.d.ts",
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,
@@ -1,115 +1,34 @@
1
- import { LocalCache, CacheInstance, FetchingFunction, CachableValue, RedisCache } from 'cachette';
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
- * The Cache class provides caching capabilities that can be used across your integration.
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
- * @see {@link Cache.create}
8
+ * @param redisUrl - The redis url to connect to (optional).
9
+ * @returns A cache instance.
10
10
  */
11
- export class Cache {
12
- private cacheInstance: CacheInstance;
13
-
14
- private constructor(cacheInstance: CacheInstance) {
15
- this.cacheInstance = cacheInstance;
16
- }
17
-
18
- /**
19
- * Get or fetch a value
20
- *
21
- * @param key The key of the value to get
22
- * @param ttl The time to live of the value in seconds.
23
- * @param fetchFn The function that can retrieve the original value
24
- * @param lockTtl Global distributed lock TTL (in seconds) protecting fetching.
25
- * If undefined, 0 or falsy, locking is not preformed
26
- * @param shouldCacheError A callback being passed errors, controlling whether
27
- * to cache or not errors. Defaults to never cache.
28
- *
29
- * @returns The cached or fetched value
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 Cache);
12
- assert.ok(cache['cacheInstance'] instanceof LocalCache);
11
+ assert.ok(cache instanceof LocalCache);
13
12
 
14
- await cache['cacheInstance'].quit();
13
+ await cache.quit();
15
14
  });
16
15
 
17
16
  it('redis url tries to return a RedisCache', () => {