Skip to content

Commit 5145ba0

Browse files
Merge pull request #356 from plivo/VT-9003
VT-9003: Plivo SDK API Response Time Degrades Over Time
2 parents 75d6590 + 9763f93 commit 5145ba0

File tree

3 files changed

+137
-74
lines changed

3 files changed

+137
-74
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Change Log
22

3+
## [v4.71.0](https://github.com/plivo/plivo-node/tree/v4.71.0) (2025-06-13)
4+
**Bug Fix**
5+
- Added HTTP/HTTPS agent configuration with connection pooling to prevent progressive API latency increase.
6+
- Fixed memory leak in retryWrapper interceptors that accumulated over time causing CPU spikes and performance degradation.
7+
- Improved resource cleanup in voice request handling to maintain stable memory usage and eliminate need for server restarts
8+
39
## [v4.70.0](https://github.com/plivo/plivo-node/tree/v4.70.0) (2025-04-30)
410
**Feature - New Param added for Start Recording API.**
511
- Support `record_channel_type` in Start Recording API and `recordChannelType` in Record XML.

lib/rest/axios.js

Lines changed: 130 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,33 @@ import * as _ from "lodash";
33

44
import axios from 'axios';
55
import queryString from 'querystring';
6+
import http from 'http';
7+
import https from 'https';
68

79
var HttpsProxyAgent = require('https-proxy-agent');
810

11+
const httpAgent = new http.Agent({
12+
keepAlive: true,
13+
keepAliveMsecs: 1000,
14+
maxSockets: 100, // Maximum sockets per host - requests queue when limit reached
15+
maxFreeSockets: 20, // Maximum free sockets per host (increased proportionally)
16+
timeout: 60000, // Socket timeout - requests fail if this timeout is reached
17+
freeSocketTimeout: 30000 // Free socket timeout
18+
});
19+
20+
const httpsAgent = new https.Agent({
21+
keepAlive: true,
22+
keepAliveMsecs: 1000,
23+
maxSockets: 100, // Requests queue when limit reached, don't fail immediately
24+
maxFreeSockets: 20, // Increased proportionally with maxSockets
25+
timeout: 60000, // Only fail if timeout reached while waiting for socket
26+
freeSocketTimeout: 30000
27+
});
28+
29+
// Set default agents for axios
30+
axios.defaults.httpAgent = httpAgent;
31+
axios.defaults.httpsAgent = httpsAgent;
32+
933
export function Axios(config) {
1034
let auth = 'Basic ' + new Buffer(config.authId + ':' + config.authToken)
1135
.toString('base64');
@@ -24,20 +48,37 @@ export function Axios(config) {
2448
return code == 403 && method.toLowerCase() == "post" && GeoPermissionEndpoints.some(endpoint => url.endsWith(endpoint))
2549
}
2650

27-
const retryWrapper = (axios, options) => {
51+
const retryWrapper = (axiosInstance, options) => {
2852
const max_time = options.retryTime;
2953
let counter = 0;
30-
axios.interceptors.response.use(null, (error) => {
54+
55+
// Create a dedicated axios instance for this request to avoid global interceptor pollution
56+
const requestAxios = axios.create({
57+
httpAgent: axiosInstance.defaults.httpAgent,
58+
httpsAgent: axiosInstance.defaults.httpsAgent,
59+
timeout: axiosInstance.defaults.timeout
60+
});
61+
62+
// Add interceptor with proper cleanup
63+
const interceptorId = requestAxios.interceptors.response.use(null, (error) => {
3164
const config = error.config;
3265
if (counter < max_time && error.response && error.response.status >= 500) {
3366
counter++;
3467
config.url = options.urls[counter] + options.authId + '/' + options.action;
3568
return new Promise((resolve) => {
36-
resolve(axios(config));
37-
})
69+
resolve(requestAxios(config));
70+
});
71+
}
72+
return Promise.reject(error);
73+
});
74+
75+
return {
76+
axios: requestAxios,
77+
cleanup: () => {
78+
// Clean up the interceptor when done
79+
requestAxios.interceptors.response.eject(interceptorId);
3880
}
39-
return Promise.reject(error)
40-
})
81+
};
4182
}
4283

4384
return (method, action, params) => {
@@ -189,7 +230,15 @@ export function Axios(config) {
189230
}
190231

191232
if (typeof config.proxy !== 'undefined') {
192-
options.httpsAgent = new HttpsProxyAgent(config.proxy);
233+
// Create proxy agent with connection pooling settings
234+
options.httpsAgent = new HttpsProxyAgent(config.proxy, {
235+
keepAlive: true,
236+
keepAliveMsecs: 1000,
237+
maxSockets: 100, // Match the main configuration
238+
maxFreeSockets: 20, // Match the main configuration
239+
timeout: 60000,
240+
freeSocketTimeout: 30000
241+
});
193242
}
194243

195244
if (typeof config.timeout !== 'undefined') {
@@ -201,77 +250,85 @@ export function Axios(config) {
201250

202251
return new Promise((resolve, reject) => {
203252
if (isVoiceReq) {
204-
retryWrapper(axios, {retryTime: 2, urls: apiVoiceUris, authId: config.authId, action: action});
205-
options.url = apiVoiceUris[0] + config.authId + '/' + action;
206-
if (method === 'GET' && options.data !== '') {
207-
let query = '?' + queryString.stringify(params);
208-
options.url += query;
209-
delete options.data
253+
// Set up retry wrapper with proper cleanup
254+
const retrySetup = retryWrapper(axios, {retryTime: 2, urls: apiVoiceUris, authId: config.authId, action: action});
255+
const retryAxios = retrySetup.axios;
256+
257+
options.url = apiVoiceUris[0] + config.authId + '/' + action;
258+
if (method === 'GET' && options.data !== '') {
259+
let query = '?' + queryString.stringify(params);
260+
options.url += query;
261+
delete options.data
262+
}
263+
264+
retryAxios(options).then(response => {
265+
var exceptionClass = {
266+
400: Exceptions.InvalidRequestError,
267+
401: Exceptions.AuthenticationError,
268+
404: Exceptions.ResourceNotFoundError,
269+
405: Exceptions.InvalidRequestError,
270+
406: Exceptions.NotAcceptableError,
271+
500: Exceptions.ServerError,
272+
409: Exceptions.InvalidRequestError,
273+
422: Exceptions.InvalidRequestError,
274+
207: Exceptions.InvalidRequestError,
275+
} [response.status] || Error;
276+
277+
if (isGeoPermissionError(response)) {
278+
exceptionClass = Exceptions.GeoPermissionError
210279
}
211-
axios(options).then(response => {
212-
var exceptionClass = {
213-
400: Exceptions.InvalidRequestError,
214-
401: Exceptions.AuthenticationError,
215-
404: Exceptions.ResourceNotFoundError,
216-
405: Exceptions.InvalidRequestError,
217-
406: Exceptions.NotAcceptableError,
218-
500: Exceptions.ServerError,
219-
409: Exceptions.InvalidRequestError,
220-
422: Exceptions.InvalidRequestError,
221-
207: Exceptions.InvalidRequestError,
222-
} [response.status] || Error;
223-
224-
if (isGeoPermissionError(response)) {
225-
exceptionClass = Exceptions.GeoPermissionError
226-
}
227-
228-
if (!_.inRange(response.status, 200, 300)) {
229-
let body = response.data;
230-
if (typeof body === 'object') {
231-
reject(new exceptionClass(JSON.stringify(body)));
232-
}
233-
else {
234-
reject(new exceptionClass(body));
235-
}
236-
}
237-
resolve({
238-
response: response,
239-
body: response.data
240-
});
241-
})
242-
.catch(function (error) {
243-
if (error.response == undefined){
244-
return reject(error.stack );
280+
281+
if (!_.inRange(response.status, 200, 300)) {
282+
let body = response.data;
283+
if (typeof body === 'object') {
284+
reject(new exceptionClass(JSON.stringify(body)));
245285
}
246-
var exceptionClass = {
247-
400: Exceptions.InvalidRequestError,
248-
401: Exceptions.AuthenticationError,
249-
404: Exceptions.ResourceNotFoundError,
250-
405: Exceptions.InvalidRequestError,
251-
406: Exceptions.NotAcceptableError,
252-
500: Exceptions.ServerError,
253-
409: Exceptions.InvalidRequestError,
254-
422: Exceptions.InvalidRequestError,
255-
207: Exceptions.InvalidRequestError,
256-
} [error.response.status] || Error;
257-
258-
if (isGeoPermissionError(error.response)) {
259-
exceptionClass = Exceptions.GeoPermissionError
286+
else {
287+
reject(new exceptionClass(body));
260288
}
261-
262-
if (!_.inRange(error.response.status, 200, 300)) {
263-
let body = error.response.data;
264-
if (typeof body === 'object') {
265-
reject(new exceptionClass(error));
266-
} else {
267-
if (error.response.status >= 500) {
268-
reject(new Exceptions.ServerError(error));
269-
}
270-
reject(new exceptionClass(error));
289+
}
290+
resolve({
291+
response: response,
292+
body: response.data
293+
});
294+
})
295+
.catch(function (error) {
296+
if (error.response == undefined){
297+
return reject(error.stack );
298+
}
299+
var exceptionClass = {
300+
400: Exceptions.InvalidRequestError,
301+
401: Exceptions.AuthenticationError,
302+
404: Exceptions.ResourceNotFoundError,
303+
405: Exceptions.InvalidRequestError,
304+
406: Exceptions.NotAcceptableError,
305+
500: Exceptions.ServerError,
306+
409: Exceptions.InvalidRequestError,
307+
422: Exceptions.InvalidRequestError,
308+
207: Exceptions.InvalidRequestError,
309+
} [error.response.status] || Error;
310+
311+
if (isGeoPermissionError(error.response)) {
312+
exceptionClass = Exceptions.GeoPermissionError
313+
}
314+
315+
if (!_.inRange(error.response.status, 200, 300)) {
316+
let body = error.response.data;
317+
if (typeof body === 'object') {
318+
reject(new exceptionClass(error));
319+
} else {
320+
if (error.response.status >= 500) {
321+
reject(new Exceptions.ServerError(error));
271322
}
323+
reject(new exceptionClass(error));
272324
}
273-
reject(error.stack + '\n' + JSON.stringify(error.response.data));
274-
})
325+
}
326+
reject(error.stack + '\n' + JSON.stringify(error.response.data));
327+
})
328+
.finally(() => {
329+
// Clean up interceptors in all cases - success, error, or unexpected exception
330+
retrySetup.cleanup();
331+
})
275332
}
276333
else {
277334
axios(options).then(response => {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "plivo",
3-
"version": "4.70.0",
3+
"version": "4.71.0",
44
"description": "A Node.js SDK to make voice calls and send SMS using Plivo and to generate Plivo XML",
55
"homepage": "https://github.com/plivo/plivo-node",
66
"files": [

0 commit comments

Comments
 (0)