@trayio/axios 5.9.0 → 5.10.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.
@@ -6,10 +6,13 @@ import { FileStorage } from '@trayio/commons/file/File';
6
6
  type AxiosOptions = {
7
7
  rejectUnauthorized: boolean;
8
8
  followRedirects: boolean;
9
+ retries?: number;
9
10
  };
10
11
  export declare class AxiosHttpClient implements HttpClient {
11
12
  private readonly fileStorage;
12
13
  private readonly axiosOptions;
14
+ private readonly defaultHttpsAgent;
15
+ private readonly axiosInstance;
13
16
  constructor(fileStorage?: FileStorage, axiosOptions?: AxiosOptions);
14
17
  execute(method: HttpMethod, url: string, request: HttpRequest): TE.TaskEither<Error, HttpResponse>;
15
18
  private axiosErrorToHttpResponse;
@@ -1 +1 @@
1
- {"version":3,"file":"AxiosHttpClient.d.ts","sourceRoot":"","sources":["../../src/http/AxiosHttpClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAEvC,OAAO,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAC7D,OAAO,EAEN,UAAU,EACV,WAAW,EAEX,YAAY,EAEZ,MAAM,2BAA2B,CAAC;AAInC,OAAO,QAAQ,GAAG,QAAQ,WAAW,CAAC,CAAC;AACvC,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAMxD,KAAK,YAAY,GAAG;IACnB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,eAAe,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,qBAAa,eAAgB,YAAW,UAAU;IAEhD,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,YAAY;gBADZ,WAAW,GAAE,WAAqC,EAClD,YAAY,GAAE,YAG9B;IAGF,OAAO,CACN,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GAClB,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,YAAY,CAAC;IAuHrC,OAAO,CAAC,wBAAwB;IAmBhC,OAAO,CAAC,2BAA2B;IAUnC,YAAY,aAAc,QAAQ,cAAc,OAAO,MAAM,EAAE,MAAM,CAAC,cAKpE;CACF"}
1
+ {"version":3,"file":"AxiosHttpClient.d.ts","sourceRoot":"","sources":["../../src/http/AxiosHttpClient.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAEvC,OAAO,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAC;AAC7D,OAAO,EAEN,UAAU,EACV,WAAW,EAEX,YAAY,EAEZ,MAAM,2BAA2B,CAAC;AAWnC,OAAO,QAAQ,GAAG,QAAQ,WAAW,CAAC,CAAC;AACvC,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAMxD,KAAK,YAAY,GAAG;IACnB,kBAAkB,EAAE,OAAO,CAAC;IAC5B,eAAe,EAAE,OAAO,CAAC;IACzB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,qBAAa,eAAgB,YAAW,UAAU;IAKhD,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,YAAY;IAL9B,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAAQ;IAC1C,OAAO,CAAC,QAAQ,CAAC,aAAa,CAA8B;gBAG1C,WAAW,GAAE,WAAqC,EAClD,YAAY,GAAE,YAI9B;IAmCF,OAAO,CACN,MAAM,EAAE,UAAU,EAClB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GAClB,EAAE,CAAC,UAAU,CAAC,KAAK,EAAE,YAAY,CAAC;IA4GrC,OAAO,CAAC,wBAAwB;IAmBhC,OAAO,CAAC,2BAA2B;IAUnC,YAAY,aAAc,QAAQ,cAAc,OAAO,MAAM,EAAE,MAAM,CAAC,cAKpE;CACF"}
@@ -31,6 +31,7 @@ const O = __importStar(require("fp-ts/Option"));
31
31
  const Http_1 = require("@trayio/commons/http/Http");
32
32
  const Task_1 = require("@trayio/commons/task/Task");
33
33
  const axios_1 = __importDefault(require("axios"));
34
+ const axios_retry_1 = __importStar(require("axios-retry"));
34
35
  const https_1 = require("https");
35
36
  const FormData = require("form-data");
36
37
  const NodeFsFileStorage_1 = require("@trayio/commons/file/NodeFsFileStorage");
@@ -39,25 +40,44 @@ const function_1 = require("fp-ts/lib/function");
39
40
  class AxiosHttpClient {
40
41
  fileStorage;
41
42
  axiosOptions;
43
+ defaultHttpsAgent;
44
+ axiosInstance;
42
45
  constructor(fileStorage = new NodeFsFileStorage_1.NodeFsFileStorage(), axiosOptions = {
43
46
  rejectUnauthorized: false,
44
47
  followRedirects: true,
48
+ retries: 0,
45
49
  }) {
46
50
  this.fileStorage = fileStorage;
47
51
  this.axiosOptions = axiosOptions;
52
+ // We need a keep-alive heartbeat shorter than 350 seconds to bypass the idle timeout in AWS NAT/LB servers:
53
+ // https://repost.aws/knowledge-center/lambda-vpc-timeout
54
+ this.defaultHttpsAgent = new https_1.Agent({
55
+ keepAlive: true,
56
+ keepAliveMsecs: 42000,
57
+ rejectUnauthorized: this.axiosOptions.rejectUnauthorized,
58
+ });
59
+ // Validate and clamp retries to 0-10 range, default to 0
60
+ const retries = Math.max(0, Math.min(10, this.axiosOptions.retries ?? 0));
61
+ // Create isolated axios instance
62
+ this.axiosInstance = axios_1.default.create();
63
+ // Clear all default headers to ensure no default headers are added
64
+ this.axiosInstance.defaults.headers.common = {};
65
+ this.axiosInstance.defaults.headers.get = {};
66
+ this.axiosInstance.defaults.headers.post = {};
67
+ this.axiosInstance.defaults.headers.put = {};
68
+ this.axiosInstance.defaults.headers.patch = {};
69
+ this.axiosInstance.defaults.headers.delete = {};
70
+ // Configure retry behavior
71
+ (0, axios_retry_1.default)(this.axiosInstance, {
72
+ retries,
73
+ retryDelay: axios_retry_1.exponentialDelay,
74
+ retryCondition: (error) =>
75
+ // Retry on network errors (ECONNREFUSED, ETIMEDOUT, etc.) and 5xx errors
76
+ axios_retry_1.default.isNetworkOrIdempotentRequestError(error) ||
77
+ (error.response?.status !== undefined && error.response.status >= 500),
78
+ });
48
79
  }
49
80
  execute(method, url, request) {
50
- /*
51
- Removes default headers so that we control what we send to the server, without this, it sends default content-type and accept headers,
52
- the caller of this HttpClient interface is responsible of deciding the values of these headers, axios shouldn't try to be smart
53
- and derive these from the body or even set defaults.
54
- */
55
- axios_1.default.defaults.headers.common = {};
56
- axios_1.default.defaults.headers.get = {};
57
- axios_1.default.defaults.headers.post = {};
58
- axios_1.default.defaults.headers.put = {};
59
- axios_1.default.defaults.headers.patch = {};
60
- axios_1.default.defaults.headers.delete = {};
61
81
  const finalUrl = Object.entries(request.pathParams).reduce((acc, [key, value]) => acc.replace(`:${key}`, encodeURIComponent(value)), url);
62
82
  const preserveHeaderCasing = (0, function_1.pipe)(request.headerOptions, O.match(() => false, (headerOptions) => (0, function_1.pipe)(headerOptions.preserveHeaderCasing, O.match(() => false, (preserveCasing) => preserveCasing))));
63
83
  const headers = Object.entries(request.headers).reduce((acc, [key, value]) => {
@@ -71,15 +91,14 @@ class AxiosHttpClient {
71
91
  };
72
92
  }, {});
73
93
  let axiosConfig;
74
- const httpCertificate = (0, function_1.pipe)(request.agent, O.map((agent) => agent), O.chain((agent) => agent.certificate), O.getOrElse(() => ({})));
75
- // We need a keep-alive heartbeat shorter than 350 seconds to bypass the idle timeout in AWS NAT/LB servers:
76
- // https://repost.aws/knowledge-center/lambda-vpc-timeout
77
- const axiosHttpsAgent = new https_1.Agent({
78
- ...httpCertificate,
94
+ // Use custom certificate if provided, otherwise use the default agent
95
+ const axiosHttpsAgent = (0, function_1.pipe)(request.agent, O.chain((agent) => agent.certificate), O.match(() => this.defaultHttpsAgent, (certificate) => new https_1.Agent({
96
+ cert: certificate.cert,
97
+ key: certificate.key,
79
98
  keepAlive: true,
80
99
  keepAliveMsecs: 42000,
81
100
  rejectUnauthorized: this.axiosOptions.rejectUnauthorized,
82
- });
101
+ })));
83
102
  if (headers['content-type'] &&
84
103
  headers['content-type'].includes(Http_1.HttpContentType.MultipartRequestBody)) {
85
104
  const formData = new FormData();
@@ -101,7 +120,7 @@ class AxiosHttpClient {
101
120
  httpsAgent: axiosHttpsAgent,
102
121
  params: request.queryString,
103
122
  };
104
- return (0, Task_1.createTaskEitherFromPromiseWithSimpleError)(() => (0, axios_1.default)(axiosConfig)
123
+ return (0, Task_1.createTaskEitherFromPromiseWithSimpleError)(() => this.axiosInstance(axiosConfig)
105
124
  .then(this.axiosResponseToHttpResponse)
106
125
  .catch(this.axiosErrorToHttpResponse.bind(this)));
107
126
  }
@@ -115,7 +134,7 @@ class AxiosHttpClient {
115
134
  params: request.queryString,
116
135
  ...(this.axiosOptions.followRedirects ? {} : { maxRedirects: 0 }),
117
136
  };
118
- return (0, Task_1.createTaskEitherFromPromiseWithSimpleError)(() => (0, axios_1.default)(axiosConfig)
137
+ return (0, Task_1.createTaskEitherFromPromiseWithSimpleError)(() => this.axiosInstance(axiosConfig)
119
138
  .then(this.axiosResponseToHttpResponse)
120
139
  .catch(this.axiosErrorToHttpResponse.bind(this)));
121
140
  }
@@ -1,11 +1,199 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
2
28
  Object.defineProperty(exports, "__esModule", { value: true });
3
29
  const HttpClient_abstract_test_1 = require("@trayio/commons/http/HttpClient.abstract.test");
4
30
  const NodeFsFileStorage_1 = require("@trayio/commons/file/NodeFsFileStorage");
31
+ const E = __importStar(require("fp-ts/Either"));
32
+ const Http_1 = require("@trayio/commons/http/Http");
33
+ const BufferExtensions_1 = require("@trayio/commons/buffer/BufferExtensions");
34
+ const nock_1 = __importDefault(require("nock"));
5
35
  const AxiosHttpClient_1 = require("./AxiosHttpClient");
6
36
  describe('AxiosHttpClient Tests', () => {
7
37
  (0, HttpClient_abstract_test_1.httpClientTest)(new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
8
38
  rejectUnauthorized: false,
9
39
  followRedirects: true,
10
40
  }));
41
+ describe('Retry mechanism', () => {
42
+ afterEach(() => {
43
+ nock_1.default.cleanAll();
44
+ });
45
+ test('should retry on ECONNREFUSED error when retries > 0', async () => {
46
+ const axiosHttpClient = new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
47
+ rejectUnauthorized: false,
48
+ followRedirects: true,
49
+ retries: 3,
50
+ });
51
+ const testUrl = 'http://test-retry.example.com';
52
+ // First two attempts fail with ECONNREFUSED
53
+ (0, nock_1.default)(testUrl).get('/').times(2).replyWithError({
54
+ code: 'ECONNREFUSED',
55
+ message: 'Connection refused',
56
+ });
57
+ // Third attempt succeeds
58
+ (0, nock_1.default)(testUrl).get('/').reply(200, { success: true });
59
+ const response = await axiosHttpClient.execute(Http_1.HttpMethod.Get, testUrl, Http_1.HttpRequest.create({
60
+ headers: {},
61
+ pathParams: {},
62
+ queryString: {},
63
+ body: BufferExtensions_1.BufferExtensions.arrayBufferToReadable(new ArrayBuffer(0)),
64
+ }))();
65
+ expect(E.isRight(response)).toBe(true);
66
+ if (E.isRight(response)) {
67
+ expect(response.right.statusCode).toBe(200);
68
+ }
69
+ });
70
+ test('should retry on 500 error when retries > 0', async () => {
71
+ const axiosHttpClient = new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
72
+ rejectUnauthorized: false,
73
+ followRedirects: true,
74
+ retries: 3,
75
+ });
76
+ const testUrl = 'http://test-retry-500.example.com';
77
+ // First two attempts return 500
78
+ (0, nock_1.default)(testUrl)
79
+ .get('/')
80
+ .times(2)
81
+ .reply(500, { error: 'Internal Server Error' });
82
+ // Third attempt succeeds
83
+ (0, nock_1.default)(testUrl).get('/').reply(200, { success: true });
84
+ const response = await axiosHttpClient.execute(Http_1.HttpMethod.Get, testUrl, Http_1.HttpRequest.create({
85
+ headers: {},
86
+ pathParams: {},
87
+ queryString: {},
88
+ body: BufferExtensions_1.BufferExtensions.arrayBufferToReadable(new ArrayBuffer(0)),
89
+ }))();
90
+ expect(E.isRight(response)).toBe(true);
91
+ if (E.isRight(response)) {
92
+ expect(response.right.statusCode).toBe(200);
93
+ }
94
+ });
95
+ test('should NOT retry when retries is 0 (default)', async () => {
96
+ const axiosHttpClient = new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
97
+ rejectUnauthorized: false,
98
+ followRedirects: true,
99
+ retries: 0,
100
+ });
101
+ const testUrl = 'http://test-no-retry.example.com';
102
+ // First attempt fails
103
+ (0, nock_1.default)(testUrl).get('/').replyWithError({
104
+ code: 'ECONNREFUSED',
105
+ message: 'Connection refused',
106
+ });
107
+ // This should not be called
108
+ (0, nock_1.default)(testUrl).get('/').reply(200, { success: true });
109
+ const response = await axiosHttpClient.execute(Http_1.HttpMethod.Get, testUrl, Http_1.HttpRequest.create({
110
+ headers: {},
111
+ pathParams: {},
112
+ queryString: {},
113
+ body: BufferExtensions_1.BufferExtensions.arrayBufferToReadable(new ArrayBuffer(0)),
114
+ }))();
115
+ // Should get error response
116
+ expect(E.isRight(response)).toBe(true);
117
+ if (E.isRight(response)) {
118
+ // AxiosHttpClient converts connection errors to 500 responses
119
+ expect(response.right.statusCode).toBe(500);
120
+ }
121
+ });
122
+ test('should NOT retry when retries is omitted (uses default 0)', async () => {
123
+ const axiosHttpClient = new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
124
+ rejectUnauthorized: false,
125
+ followRedirects: true,
126
+ });
127
+ const testUrl = 'http://test-default-no-retry.example.com';
128
+ // First attempt fails
129
+ (0, nock_1.default)(testUrl).get('/').replyWithError({
130
+ code: 'ECONNREFUSED',
131
+ message: 'Connection refused',
132
+ });
133
+ // This should not be called
134
+ (0, nock_1.default)(testUrl).get('/').reply(200, { success: true });
135
+ const response = await axiosHttpClient.execute(Http_1.HttpMethod.Get, testUrl, Http_1.HttpRequest.create({
136
+ headers: {},
137
+ pathParams: {},
138
+ queryString: {},
139
+ body: BufferExtensions_1.BufferExtensions.arrayBufferToReadable(new ArrayBuffer(0)),
140
+ }))();
141
+ // Should get error response
142
+ expect(E.isRight(response)).toBe(true);
143
+ if (E.isRight(response)) {
144
+ // AxiosHttpClient converts connection errors to 500 responses
145
+ expect(response.right.statusCode).toBe(500);
146
+ }
147
+ });
148
+ test('should give up after max retries', async () => {
149
+ const axiosHttpClient = new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
150
+ rejectUnauthorized: false,
151
+ followRedirects: true,
152
+ retries: 3,
153
+ });
154
+ const testUrl = 'http://test-max-retries.example.com';
155
+ // All attempts fail (initial + 3 retries = 4 total)
156
+ (0, nock_1.default)(testUrl).get('/').times(4).replyWithError({
157
+ code: 'ECONNREFUSED',
158
+ message: 'Connection refused',
159
+ });
160
+ const response = await axiosHttpClient.execute(Http_1.HttpMethod.Get, testUrl, Http_1.HttpRequest.create({
161
+ headers: {},
162
+ pathParams: {},
163
+ queryString: {},
164
+ body: BufferExtensions_1.BufferExtensions.arrayBufferToReadable(new ArrayBuffer(0)),
165
+ }))();
166
+ // Should eventually fail
167
+ expect(E.isRight(response)).toBe(true);
168
+ if (E.isRight(response)) {
169
+ // AxiosHttpClient converts connection errors to 500 responses
170
+ expect(response.right.statusCode).toBe(500);
171
+ }
172
+ });
173
+ test('should clamp retries to max value of 10', async () => {
174
+ const axiosHttpClient = new AxiosHttpClient_1.AxiosHttpClient(new NodeFsFileStorage_1.NodeFsFileStorage(), {
175
+ rejectUnauthorized: false,
176
+ followRedirects: true,
177
+ retries: 100, // Should be clamped to 10
178
+ });
179
+ const testUrl = 'http://test-clamp-retries.example.com';
180
+ // Fail 2 times, then succeed - verifies retries work with large value
181
+ (0, nock_1.default)(testUrl).get('/').times(2).replyWithError({
182
+ code: 'ECONNREFUSED',
183
+ message: 'Connection refused',
184
+ });
185
+ (0, nock_1.default)(testUrl).get('/').reply(200, { success: true });
186
+ const response = await axiosHttpClient.execute(Http_1.HttpMethod.Get, testUrl, Http_1.HttpRequest.create({
187
+ headers: {},
188
+ pathParams: {},
189
+ queryString: {},
190
+ body: BufferExtensions_1.BufferExtensions.arrayBufferToReadable(new ArrayBuffer(0)),
191
+ }))();
192
+ // Should succeed because retries are enabled (clamped to 10)
193
+ expect(E.isRight(response)).toBe(true);
194
+ if (E.isRight(response)) {
195
+ expect(response.right.statusCode).toBe(200);
196
+ }
197
+ });
198
+ });
11
199
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trayio/axios",
3
- "version": "5.9.0",
3
+ "version": "5.10.0",
4
4
  "description": "Axios extensions and implementations",
5
5
  "exports": {
6
6
  "./*": "./dist/*.js"
@@ -14,8 +14,9 @@
14
14
  "access": "public"
15
15
  },
16
16
  "dependencies": {
17
- "@trayio/commons": "5.9.0",
17
+ "@trayio/commons": "5.10.0",
18
18
  "axios": "1.12.0",
19
+ "axios-retry": "4.5.0",
19
20
  "form-data": "4.0.4"
20
21
  },
21
22
  "typesVersions": {