Skip to content
This repository was archived by the owner on Mar 11, 2022. It is now read-only.

Commit 86f6d3d

Browse files
authored
Merge pull request #457 from cloudant/456-retry-timeout-hang
456 retry timeout hang
2 parents 33f76ed + 0bd1087 commit 86f6d3d

File tree

5 files changed

+167
-3
lines changed

5 files changed

+167
-3
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# UNRELEASED
2+
- [FIXED] Hang caused by plugins (i.e. retry plugin) preventing callback execution
3+
by attempting to retry on errors received after starting to return the response body.
24
- [DEPRECATED] This library is now deprecated and will be EOL on Dec 31 2021.
35

46
# 4.4.0 (2021-06-18)

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,10 @@ var cloudant = Cloudant({ url: myurl, maxAttempt: 5, plugins: [ { iamauth: { iam
511511
- `retryErrors`
512512
513513
Automatically retry a request on error (e.g. connection reset errors)
514-
_(default: true)_.
514+
_(default: true)_. Note that this will only retry errors encountered
515+
_before_ the library starts to read response body data. After that point
516+
any errors (e.g. socket timeout reading from the server) will be returned
517+
to the caller (via callback or emitted `error` depending on the usage).
515518
516519
- `retryInitialDelayMsecs`
517520

lib/clientutils.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ var processState = function(r, callback) {
120120
}
121121

122122
// [5] => Return response to awaiting client.
123+
r.state.final = true; // flags that we are terminating the loop
123124
setImmediate(callback, new Error('No retry requested')); // no retry
124125
};
125126

@@ -157,7 +158,7 @@ var wrapCallback = function(r, done) {
157158
if (error) {
158159
runHooks('onError', r, error, function() {
159160
processState(r, function(stop) {
160-
if (stop && !stop.skipClientCallback) {
161+
if ((stop && !stop.skipClientCallback) || r.state.final) {
161162
r.clientCallback(error, response, body);
162163
}
163164
done(stop);

plugins/retry.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
'use strict';
1515

1616
const BasePlugin = require('./base.js');
17-
17+
const debug = require('debug')('cloudant:plugins:retry');
1818
/**
1919
* Retry plugin.
2020
*/
@@ -38,24 +38,28 @@ class RetryPlugin extends BasePlugin {
3838

3939
onResponse(state, response, callback) {
4040
if (this._cfg.retryStatusCodes.indexOf(response.statusCode) !== -1) {
41+
debug(`Received status code ${response.statusCode}; setting retry state.`);
4142
state.retry = true;
4243
if (state.attempt === 1) {
4344
state.retryDelayMsecs = this._cfg.retryInitialDelayMsecs;
4445
} else {
4546
state.retryDelayMsecs *= this._cfg.retryDelayMultiplier;
4647
}
48+
debug(`Asking for retry after ${state.retryDelayMsecs}`);
4749
}
4850
callback(state);
4951
}
5052

5153
onError(state, error, callback) {
5254
if (this._cfg.retryErrors) {
55+
debug(`Received error ${error.code} ${error.message}; setting retry state.`);
5356
state.retry = true;
5457
if (state.attempt === 1) {
5558
state.retryDelayMsecs = this._cfg.retryInitialDelayMsecs;
5659
} else {
5760
state.retryDelayMsecs *= this._cfg.retryDelayMultiplier;
5861
}
62+
debug(`Asking for retry after ${state.retryDelayMsecs}`);
5963
}
6064
callback(state);
6165
}

test/plugins/retry.js

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,38 @@ describe('Retry Plugin', function() {
860860
});
861861

862862
describe('retries on error', function() {
863+
function dataPhaseErrorMockServer(port, timesToError, successBody, lifetime, done) {
864+
// nock is unable to mock the timeout in the response phase so create a mock http server
865+
const http = require('http');
866+
var counter = 0;
867+
const mockServer = http.createServer({}, function(req, res) {
868+
counter++;
869+
console.log(`DPE MOCK SERVER: received request ${counter} for URL: ${req.url}`);
870+
if (counter <= timesToError) {
871+
res.writeHead(200, { 'Content-Type': 'application/json' });
872+
res.write(successBody);
873+
// We intentionally fail to end the request so that the client side will timeout in the data phase
874+
// res.end();
875+
} else {
876+
// respond successfully promptly
877+
res.end(successBody);
878+
}
879+
}).listen(port, '127.0.0.1');
880+
// set a timeout so we don't hang the test (mocha's timeout won't kill the http server)
881+
const serverTimeout = setTimeout(() => {
882+
console.log('DPE MOCK SERVER: timeout');
883+
if (mockServer.listening) {
884+
console.log('DPE MOCK SERVER: close requested from timeout');
885+
mockServer.close(() => done(new Error('Test server timeout; client hang!')));
886+
}
887+
}, lifetime);
888+
return {server: mockServer,
889+
close: () => {
890+
clearTimeout(serverTimeout);
891+
mockServer.close(done);
892+
}};
893+
}
894+
863895
describe('with callback only', function() {
864896
it('performs request and returns response', function(done) {
865897
// NOTE: Use NOCK_OFF=true to test using a real CouchDB instance.
@@ -945,6 +977,31 @@ describe('Retry Plugin', function() {
945977
done();
946978
});
947979
});
980+
981+
it('does not retry in response data phase', function(done) {
982+
// mock server on 6984 that errors for 1 request then returns the DB info block for other reqs
983+
// and finally stops listening after 1s if it wasn't already closed
984+
const mock = dataPhaseErrorMockServer(6984, 1, '{"doc_count":0}', 1000, done);
985+
// A client that will timeout after 100 ms and make only 2 attempts with a retry 10 ms after receiving an error
986+
var cloudantClient = new Client({
987+
https: false,
988+
maxAttempt: 2,
989+
requestDefaults: {timeout: 100},
990+
plugins: { retry: { retryInitialDelayMsecs: 10, retryStatusCodes: [] } }
991+
});
992+
993+
var req = {
994+
url: 'http://127.0.0.1:6984/foo',
995+
auth: { username: ME, password: PASSWORD },
996+
method: 'GET'
997+
};
998+
999+
cloudantClient.request(req, function(err, resp, data) {
1000+
assert.ok(err, 'Should get called back with an error.');
1001+
assert.equal('ESOCKETTIMEDOUT', err.code);
1002+
mock.close();
1003+
});
1004+
});
9481005
});
9491006

9501007
describe('with listener only', function() {
@@ -1084,6 +1141,54 @@ describe('Retry Plugin', function() {
10841141
assert.fail('Unexpected data from server');
10851142
});
10861143
});
1144+
1145+
it('does not retry in response data phase', function(done) {
1146+
// mock server on 6985 that errors for 1 request then returns the DB info block for other reqs
1147+
// and finally stops listening after 1s if it wasn't already closed
1148+
const mock = dataPhaseErrorMockServer(6985, 1, '{"doc_count":0}', 1000, done);
1149+
1150+
// A client that will timeout after 100 ms and make only 2 attempts with a retry 10 ms after receiving an error
1151+
var cloudantClient = new Client({
1152+
https: false,
1153+
maxAttempt: 2,
1154+
requestDefaults: {timeout: 100},
1155+
plugins: { retry: { retryInitialDelayMsecs: 10, retryStatusCodes: [] } }
1156+
});
1157+
1158+
var req = {
1159+
url: 'http://127.0.0.1:6985/foo',
1160+
auth: { username: ME, password: PASSWORD },
1161+
method: 'GET'
1162+
};
1163+
1164+
var responseCount = 0;
1165+
var errors = [];
1166+
var responseData = '';
1167+
1168+
cloudantClient.request(req)
1169+
.on('error', (err) => {
1170+
errors.push(err);
1171+
})
1172+
.on('response', function(resp) {
1173+
responseCount++;
1174+
assert.equal(resp.statusCode, 200);
1175+
})
1176+
.on('data', function(data) {
1177+
responseData += data;
1178+
})
1179+
.on('end', function() {
1180+
let expectedErrors = 1;
1181+
if (process.version.startsWith('v16.')) {
1182+
// Node 16 has an additional `aborted` error
1183+
// https://github.com/nodejs/node/issues/28172
1184+
expectedErrors++;
1185+
}
1186+
assert.ok(responseData.toString('utf8').indexOf('"doc_count":0') > -1);
1187+
assert.equal(responseCount, 1);
1188+
assert.equal(errors.length, expectedErrors);
1189+
mock.close(done);
1190+
});
1191+
});
10871192
});
10881193

10891194
describe('with callback and listener', function() {
@@ -1235,6 +1340,55 @@ describe('Retry Plugin', function() {
12351340
assert.fail('Unexpected data from server');
12361341
});
12371342
});
1343+
1344+
it('does not retry in response data phase', function(done) {
1345+
// mock server on 6986 that errors for 1 request then returns the DB info block for other reqs
1346+
// and finally stops listening after 1s if it wasn't already closed
1347+
const mock = dataPhaseErrorMockServer(6986, 1, '{"doc_count":0}', 1000, done);
1348+
// A client that will timeout after 100 ms and make only 2 attempts with a retry 10 ms after receiving an error
1349+
var cloudantClient = new Client({
1350+
https: false,
1351+
maxAttempt: 2,
1352+
requestDefaults: {timeout: 100},
1353+
plugins: { retry: { retryInitialDelayMsecs: 10, retryStatusCodes: [] } }
1354+
});
1355+
1356+
var req = {
1357+
url: 'http://127.0.0.1:6986/foo',
1358+
auth: { username: ME, password: PASSWORD },
1359+
method: 'GET'
1360+
};
1361+
1362+
var responseCount = 0;
1363+
var errors = [];
1364+
var responseData = '';
1365+
cloudantClient.request(req, function(err, resp, data) {
1366+
assert.ok(err, 'Should get called back with an error.');
1367+
assert.equal('ESOCKETTIMEDOUT', err.code);
1368+
mock.close();
1369+
})
1370+
.on('error', (err) => {
1371+
errors.push(err);
1372+
})
1373+
.on('response', function(resp) {
1374+
responseCount++;
1375+
assert.equal(resp.statusCode, 200);
1376+
})
1377+
.on('data', function(data) {
1378+
responseData += data;
1379+
})
1380+
.on('end', function() {
1381+
let expectedErrors = 1;
1382+
if (process.version.startsWith('v16.')) {
1383+
// Node 16 has an additional `aborted` error
1384+
// https://github.com/nodejs/node/issues/28172
1385+
expectedErrors++;
1386+
}
1387+
assert.ok(responseData.toString('utf8').indexOf('"doc_count":0') > -1);
1388+
assert.equal(responseCount, 1);
1389+
assert.equal(errors.length, expectedErrors);
1390+
});
1391+
});
12381392
});
12391393
});
12401394
});

0 commit comments

Comments
 (0)