Skip to content

Commit 50536d1

Browse files
authored
fix: premature close with chunked transfer encoding and for async iterators in Node 12 (#1172)
* fix: premature close with chunked transfer encoding and for async iterators in Node 12 This PR backports the fix from #1064 to the `2.x.x` branch following the [comment here](#1064 (comment)). I had to add some extra babel config to allow using the `for await..of` syntax in the tests. The config is only needed for the tests as this syntax is not used in the implementation. * chore: fix up tests for node 6+ * chore: codecov dropped support for node < 8 without shipping major * chore: npm7 strips empty dependencies hash during install * chore: pin deps to versions that work on node 4 * chore: do not emit close error after aborting a request * chore: test on node 4-16 * chore: simplify chunked transer encoding bad ending * chore: avoid calling .destroy as it is not in every node.js release * chore: listen for response close as socket is reused and shows warnings
1 parent 838d971 commit 50536d1

File tree

7 files changed

+221
-4
lines changed

7 files changed

+221
-4
lines changed

.babelrc

+4-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
} ]
1515
],
1616
plugins: [
17-
'./build/babel-plugin'
17+
'./build/babel-plugin',
18+
'transform-async-generator-functions'
1819
]
1920
},
2021
coverage: {
@@ -31,7 +32,8 @@
3132
],
3233
plugins: [
3334
[ 'istanbul', { exclude: [ 'src/blob.js', 'build', 'test' ] } ],
34-
'./build/babel-plugin'
35+
'./build/babel-plugin',
36+
'transform-async-generator-functions'
3537
]
3638
},
3739
rollup: {

.travis.yml

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ node_js:
44
- "6"
55
- "8"
66
- "10"
7+
- "12"
8+
- "14"
79
- "node"
810
env:
911
- FORMDATA_VERSION=1.0.0

README.md

+43
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,49 @@ fetch('https://assets-cdn.github.com/images/modules/logos_page/Octocat.png')
188188
});
189189
```
190190

191+
In Node.js 14 you can also use async iterators to read `body`; however, be careful to catch
192+
errors -- the longer a response runs, the more likely it is to encounter an error.
193+
194+
```js
195+
const fetch = require('node-fetch');
196+
const response = await fetch('https://httpbin.org/stream/3');
197+
try {
198+
for await (const chunk of response.body) {
199+
console.dir(JSON.parse(chunk.toString()));
200+
}
201+
} catch (err) {
202+
console.error(err.stack);
203+
}
204+
```
205+
206+
In Node.js 12 you can also use async iterators to read `body`; however, async iterators with streams
207+
did not mature until Node.js 14, so you need to do some extra work to ensure you handle errors
208+
directly from the stream and wait on it response to fully close.
209+
210+
```js
211+
const fetch = require('node-fetch');
212+
const read = async body => {
213+
let error;
214+
body.on('error', err => {
215+
error = err;
216+
});
217+
for await (const chunk of body) {
218+
console.dir(JSON.parse(chunk.toString()));
219+
}
220+
return new Promise((resolve, reject) => {
221+
body.on('close', () => {
222+
error ? reject(error) : resolve();
223+
});
224+
});
225+
};
226+
try {
227+
const response = await fetch('https://httpbin.org/stream/3');
228+
await read(response.body);
229+
} catch (err) {
230+
console.error(err.stack);
231+
}
232+
```
233+
191234
#### Buffer
192235
If you prefer to cache binary data in full, use buffer(). (NOTE: `buffer()` is a `node-fetch`-only API)
193236

package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@
5353
"abortcontroller-polyfill": "^1.3.0",
5454
"babel-core": "^6.26.3",
5555
"babel-plugin-istanbul": "^4.1.6",
56-
"babel-preset-env": "^1.6.1",
56+
"babel-plugin-transform-async-generator-functions": "^6.24.1",
57+
"babel-polyfill": "^6.26.0",
58+
"babel-preset-env": "1.4.0",
5759
"babel-register": "^6.16.3",
5860
"chai": "^3.5.0",
5961
"chai-as-promised": "^7.1.1",

src/index.js

+67-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export default function fetch(url, opts) {
6767
let error = new AbortError('The user aborted a request.');
6868
reject(error);
6969
if (request.body && request.body instanceof Stream.Readable) {
70-
request.body.destroy(error);
70+
destroyStream(request.body, error);
7171
}
7272
if (!response || !response.body) return;
7373
response.body.emit('error', error);
@@ -108,9 +108,41 @@ export default function fetch(url, opts) {
108108

109109
req.on('error', err => {
110110
reject(new FetchError(`request to ${request.url} failed, reason: ${err.message}`, 'system', err));
111+
112+
if (response && response.body) {
113+
destroyStream(response.body, err);
114+
}
115+
111116
finalize();
112117
});
113118

119+
fixResponseChunkedTransferBadEnding(req, err => {
120+
if (signal && signal.aborted) {
121+
return
122+
}
123+
124+
destroyStream(response.body, err);
125+
});
126+
127+
/* c8 ignore next 18 */
128+
if (parseInt(process.version.substring(1)) < 14) {
129+
// Before Node.js 14, pipeline() does not fully support async iterators and does not always
130+
// properly handle when the socket close/end events are out of order.
131+
req.on('socket', s => {
132+
s.addListener('close', hadError => {
133+
// if a data listener is still present we didn't end cleanly
134+
const hasDataListener = s.listenerCount('data') > 0
135+
136+
// if end happened before close but the socket didn't emit an error, do it now
137+
if (response && hasDataListener && !hadError && !(signal && signal.aborted)) {
138+
const err = new Error('Premature close');
139+
err.code = 'ERR_STREAM_PREMATURE_CLOSE';
140+
response.body.emit('error', err);
141+
}
142+
});
143+
});
144+
}
145+
114146
req.on('response', res => {
115147
clearTimeout(reqTimeout);
116148

@@ -303,6 +335,40 @@ export default function fetch(url, opts) {
303335

304336
};
305337

338+
function fixResponseChunkedTransferBadEnding(request, errorCallback) {
339+
let socket;
340+
341+
request.on('socket', s => {
342+
socket = s;
343+
});
344+
345+
request.on('response', response => {
346+
const {headers} = response;
347+
if (headers['transfer-encoding'] === 'chunked' && !headers['content-length']) {
348+
response.once('close', hadError => {
349+
// if a data listener is still present we didn't end cleanly
350+
const hasDataListener = socket.listenerCount('data') > 0;
351+
352+
if (hasDataListener && !hadError) {
353+
const err = new Error('Premature close');
354+
err.code = 'ERR_STREAM_PREMATURE_CLOSE';
355+
errorCallback(err);
356+
}
357+
});
358+
}
359+
});
360+
}
361+
362+
function destroyStream (stream, err) {
363+
if (stream.destroy) {
364+
stream.destroy(err);
365+
} else {
366+
// node < 8
367+
stream.emit('error', err);
368+
stream.end();
369+
}
370+
}
371+
306372
/**
307373
* Redirect code matching
308374
*

test/server.js

+28
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,34 @@ export default class TestServer {
329329
res.destroy();
330330
}
331331

332+
if (p === '/error/premature/chunked') {
333+
res.writeHead(200, {
334+
'Content-Type': 'application/json',
335+
'Transfer-Encoding': 'chunked'
336+
});
337+
338+
// Transfer-Encoding: 'chunked' sends chunk sizes followed by the
339+
// chunks - https://en.wikipedia.org/wiki/Chunked_transfer_encoding
340+
const sendChunk = (obj) => {
341+
const data = JSON.stringify(obj)
342+
343+
res.write(`${data.length}\r\n`)
344+
res.write(`${data}\r\n`)
345+
}
346+
347+
sendChunk({data: 'hi'})
348+
349+
setTimeout(() => {
350+
sendChunk({data: 'bye'})
351+
}, 200);
352+
353+
setTimeout(() => {
354+
// should send '0\r\n\r\n' to end the response properly but instead
355+
// just close the connection
356+
res.destroy();
357+
}, 400);
358+
}
359+
332360
if (p === '/error/json') {
333361
res.statusCode = 200;
334362
res.setHeader('Content-Type', 'application/json');

test/test.js

+74
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11

2+
import 'babel-core/register'
3+
import 'babel-polyfill'
4+
25
// test tools
36
import chai from 'chai';
47
import chaiPromised from 'chai-as-promised';
@@ -552,6 +555,77 @@ describe('node-fetch', () => {
552555
.and.have.property('code', 'ECONNRESET');
553556
});
554557

558+
it('should handle network-error in chunked response', () => {
559+
const url = `${base}error/premature/chunked`;
560+
return fetch(url).then(res => {
561+
expect(res.status).to.equal(200);
562+
expect(res.ok).to.be.true;
563+
564+
return expect(new Promise((resolve, reject) => {
565+
res.body.on('error', reject);
566+
res.body.on('close', resolve);
567+
})).to.eventually.be.rejectedWith(Error, 'Premature close')
568+
.and.have.property('code', 'ERR_STREAM_PREMATURE_CLOSE');
569+
});
570+
});
571+
572+
// Skip test if streams are not async iterators (node < 10)
573+
const itAsyncIterator = Boolean(new stream.PassThrough()[Symbol.asyncIterator]) ? it : it.skip;
574+
575+
itAsyncIterator('should handle network-error in chunked response async iterator', () => {
576+
const url = `${base}error/premature/chunked`;
577+
return fetch(url).then(res => {
578+
expect(res.status).to.equal(200);
579+
expect(res.ok).to.be.true;
580+
581+
const read = async body => {
582+
const chunks = [];
583+
584+
if (process.version < 'v14') {
585+
// In Node.js 12, some errors don't come out in the async iterator; we have to pick
586+
// them up from the event-emitter and then throw them after the async iterator
587+
let error;
588+
body.on('error', err => {
589+
error = err;
590+
});
591+
592+
for await (const chunk of body) {
593+
chunks.push(chunk);
594+
}
595+
596+
if (error) {
597+
throw error;
598+
}
599+
600+
return new Promise(resolve => {
601+
body.on('close', () => resolve(chunks));
602+
});
603+
}
604+
605+
for await (const chunk of body) {
606+
chunks.push(chunk);
607+
}
608+
609+
return chunks;
610+
};
611+
612+
return expect(read(res.body))
613+
.to.eventually.be.rejectedWith(Error, 'Premature close')
614+
.and.have.property('code', 'ERR_STREAM_PREMATURE_CLOSE');
615+
});
616+
});
617+
618+
it('should handle network-error in chunked response in consumeBody', () => {
619+
const url = `${base}error/premature/chunked`;
620+
return fetch(url).then(res => {
621+
expect(res.status).to.equal(200);
622+
expect(res.ok).to.be.true;
623+
624+
return expect(res.text())
625+
.to.eventually.be.rejectedWith(Error, 'Premature close');
626+
});
627+
});
628+
555629
it('should handle DNS-error response', function() {
556630
const url = 'http://domain.invalid';
557631
return expect(fetch(url)).to.eventually.be.rejected

0 commit comments

Comments
 (0)