Skip to content

Commit 989dfaf

Browse files
mcollinaMylesBorins
authored andcommitted
http2: refactor error handling
This changes the error handling model of ServerHttp2Stream, ServerHttp2Request and ServerHttp2Response. An 'error' emitted on ServerHttp2Stream will not go to 'uncaughtException' anymore, but to the server 'streamError'. On the stream 'error', ServerHttp2Request will emit 'abort', while ServerHttp2Response would do nothing. It also updates respondWith* to the new error handling. Fixes: #14963 PR-URL: #14991 Reviewed-By: James M Snell <[email protected]> Reviewed-By: Benjamin Gruenbaum <[email protected]>
1 parent f694ea6 commit 989dfaf

9 files changed

+329
-33
lines changed

doc/api/http2.md

+35-4
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,8 @@ added: v8.4.0
11181118
* `headers` {[Headers Object][]}
11191119
* `options` {Object}
11201120
* `statCheck` {Function}
1121+
* `onError` {Function} Callback function invoked in the case of an
1122+
Error before send
11211123
* `getTrailers` {Function} Callback function invoked to collect trailer
11221124
headers.
11231125
* `offset` {number} The offset position at which to begin reading
@@ -1146,6 +1148,16 @@ server.on('stream', (stream) => {
11461148
function statCheck(stat, headers) {
11471149
headers['last-modified'] = stat.mtime.toUTCString();
11481150
}
1151+
1152+
function onError(err) {
1153+
if (err.code === 'ENOENT') {
1154+
stream.respond({ ':status': 404 });
1155+
} else {
1156+
stream.respond({ ':status': 500 });
1157+
}
1158+
stream.end();
1159+
}
1160+
11491161
stream.respondWithFile('/some/file',
11501162
{ 'content-type': 'text/plain' },
11511163
{ statCheck });
@@ -1178,6 +1190,10 @@ The `offset` and `length` options may be used to limit the response to a
11781190
specific range subset. This can be used, for instance, to support HTTP Range
11791191
requests.
11801192

1193+
The `options.onError` function may also be used to handle all the errors
1194+
that could happen before the delivery of the file is initiated. The
1195+
default behavior is to destroy the stream.
1196+
11811197
When set, the `options.getTrailers()` function is called immediately after
11821198
queuing the last chunk of payload data to be sent. The callback is passed a
11831199
single object (with a `null` prototype) that the listener may used to specify
@@ -1208,6 +1224,19 @@ added: v8.4.0
12081224

12091225
* Extends: {net.Server}
12101226

1227+
In `Http2Server`, there is no `'clientError'` event as there is in
1228+
HTTP1. However, there are `'socketError'`, `'sessionError'`, and
1229+
`'streamError'`, for error happened on the socket, session or stream
1230+
respectively.
1231+
1232+
#### Event: 'socketError'
1233+
<!-- YAML
1234+
added: v8.4.0
1235+
-->
1236+
1237+
The `'socketError'` event is emitted when a `'socketError'` event is emitted by
1238+
an `Http2Session` associated with the server.
1239+
12111240
#### Event: 'sessionError'
12121241
<!-- YAML
12131242
added: v8.4.0
@@ -1217,13 +1246,15 @@ The `'sessionError'` event is emitted when an `'error'` event is emitted by
12171246
an `Http2Session` object. If no listener is registered for this event, an
12181247
`'error'` event is emitted.
12191248

1220-
#### Event: 'socketError'
1249+
#### Event: 'streamError'
12211250
<!-- YAML
1222-
added: v8.4.0
1251+
added: REPLACEME
12231252
-->
12241253

1225-
The `'socketError'` event is emitted when a `'socketError'` event is emitted by
1226-
an `Http2Session` associated with the server.
1254+
* `socket` {http2.ServerHttp2Stream}
1255+
1256+
If an `ServerHttp2Stream` emits an `'error'` event, it will be forwarded here.
1257+
The stream will already be destroyed when this event is triggered.
12271258

12281259
#### Event: 'stream'
12291260
<!-- YAML

lib/http2.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const {
1515
createSecureServer,
1616
connect,
1717
Http2ServerRequest,
18-
Http2ServerResponse,
18+
Http2ServerResponse
1919
} = require('internal/http2/core');
2020

2121
module.exports = {
@@ -27,5 +27,5 @@ module.exports = {
2727
createSecureServer,
2828
connect,
2929
Http2ServerResponse,
30-
Http2ServerRequest,
30+
Http2ServerRequest
3131
};

lib/internal/http2/compat.js

+7-9
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ function onStreamEnd() {
5858
}
5959

6060
function onStreamError(error) {
61-
const request = this[kRequest];
62-
request.emit('error', error);
61+
// this is purposefully left blank
62+
//
63+
// errors in compatibility mode are
64+
// not forwarded to the request
65+
// and response objects. However,
66+
// they are forwarded to 'clientError'
67+
// on the server by Http2Stream
6368
}
6469

6570
function onRequestPause() {
@@ -82,11 +87,6 @@ function onStreamResponseDrain() {
8287
response.emit('drain');
8388
}
8489

85-
function onStreamResponseError(error) {
86-
const response = this[kResponse];
87-
response.emit('error', error);
88-
}
89-
9090
function onStreamClosedRequest() {
9191
const req = this[kRequest];
9292
req.push(null);
@@ -271,9 +271,7 @@ class Http2ServerResponse extends Stream {
271271
stream[kResponse] = this;
272272
this.writable = true;
273273
stream.on('drain', onStreamResponseDrain);
274-
stream.on('error', onStreamResponseError);
275274
stream.on('close', onStreamClosedResponse);
276-
stream.on('aborted', onAborted.bind(this));
277275
const onfinish = this[kFinish].bind(this);
278276
stream.on('streamClosed', onfinish);
279277
stream.on('finish', onfinish);

lib/internal/http2/core.js

+37-6
Original file line numberDiff line numberDiff line change
@@ -1506,6 +1506,13 @@ class Http2Stream extends Duplex {
15061506
this.once('ready', this._destroy.bind(this, err, callback));
15071507
return;
15081508
}
1509+
1510+
const server = session[kServer];
1511+
1512+
if (err && server) {
1513+
server.emit('streamError', err, this);
1514+
}
1515+
15091516
process.nextTick(() => {
15101517
debug(`[${sessionName(session[kType])}] destroying stream ${this[kID]}`);
15111518

@@ -1529,9 +1536,8 @@ class Http2Stream extends Duplex {
15291536
// All done
15301537
const rst = this[kState].rst;
15311538
const code = rst ? this[kState].rstCode : NGHTTP2_NO_ERROR;
1532-
if (code !== NGHTTP2_NO_ERROR) {
1533-
const err = new errors.Error('ERR_HTTP2_STREAM_ERROR', code);
1534-
process.nextTick(() => this.emit('error', err));
1539+
if (!err && code !== NGHTTP2_NO_ERROR) {
1540+
err = new errors.Error('ERR_HTTP2_STREAM_ERROR', code);
15351541
}
15361542
process.nextTick(emit.bind(this, 'streamClosed', code));
15371543
debug(`[${sessionName(session[kType])}] stream ${this[kID]} destroyed`);
@@ -1634,13 +1640,24 @@ function doSendFileFD(session, options, fd, headers, getTrailers, err, stat) {
16341640
abort(this);
16351641
return;
16361642
}
1643+
const onError = options.onError;
1644+
16371645
if (err) {
1638-
process.nextTick(() => this.emit('error', err));
1646+
if (onError) {
1647+
onError(err);
1648+
} else {
1649+
this.destroy(err);
1650+
}
16391651
return;
16401652
}
1653+
16411654
if (!stat.isFile()) {
16421655
err = new errors.Error('ERR_HTTP2_SEND_FILE');
1643-
process.nextTick(() => this.emit('error', err));
1656+
if (onError) {
1657+
onError(err);
1658+
} else {
1659+
this.destroy(err);
1660+
}
16441661
return;
16451662
}
16461663

@@ -1677,12 +1694,17 @@ function doSendFileFD(session, options, fd, headers, getTrailers, err, stat) {
16771694

16781695
function afterOpen(session, options, headers, getTrailers, err, fd) {
16791696
const state = this[kState];
1697+
const onError = options.onError;
16801698
if (this.destroyed || session.destroyed) {
16811699
abort(this);
16821700
return;
16831701
}
16841702
if (err) {
1685-
process.nextTick(() => this.emit('error', err));
1703+
if (onError) {
1704+
onError(err);
1705+
} else {
1706+
this.destroy(err);
1707+
}
16861708
return;
16871709
}
16881710
state.fd = fd;
@@ -1691,13 +1713,20 @@ function afterOpen(session, options, headers, getTrailers, err, fd) {
16911713
doSendFileFD.bind(this, session, options, fd, headers, getTrailers));
16921714
}
16931715

1716+
function streamOnError(err) {
1717+
// we swallow the error for parity with HTTP1
1718+
// all the errors that ends here are not critical for the project
1719+
debug('ServerHttp2Stream errored, avoiding uncaughtException', err);
1720+
}
1721+
16941722

16951723
class ServerHttp2Stream extends Http2Stream {
16961724
constructor(session, id, options, headers) {
16971725
super(session, options);
16981726
this[kInit](id);
16991727
this[kProtocol] = headers[HTTP2_HEADER_SCHEME];
17001728
this[kAuthority] = headers[HTTP2_HEADER_AUTHORITY];
1729+
this.on('error', streamOnError);
17011730
debug(`[${sessionName(session[kType])}] created serverhttp2stream`);
17021731
}
17031732

@@ -2556,6 +2585,8 @@ module.exports = {
25562585
createServer,
25572586
createSecureServer,
25582587
connect,
2588+
Http2Session,
2589+
Http2Stream,
25592590
Http2ServerRequest,
25602591
Http2ServerResponse
25612592
};

test/parallel/test-http2-client-stream-destroy-before-connect.js

+5-12
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,11 @@ server.on('listening', common.mustCall(() => {
3636
req.destroy(err);
3737

3838
req.on('error', common.mustCall((err) => {
39-
const fn = err.code === 'ERR_HTTP2_STREAM_ERROR' ?
40-
common.expectsError({
41-
code: 'ERR_HTTP2_STREAM_ERROR',
42-
type: Error,
43-
message: 'Stream closed with error code 2'
44-
}) :
45-
common.expectsError({
46-
type: Error,
47-
message: 'test'
48-
});
49-
fn(err);
50-
}, 2));
39+
common.expectsError({
40+
type: Error,
41+
message: 'test'
42+
})(err);
43+
}));
5144

5245
req.on('streamClosed', common.mustCall((code) => {
5346
assert.strictEqual(req.rstCode, NGHTTP2_INTERNAL_ERROR);
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Flags: --expose-http2 --expose-internals
2+
'use strict';
3+
4+
const common = require('../common');
5+
if (!common.hasCrypto)
6+
common.skip('missing crypto');
7+
const assert = require('assert');
8+
const h2 = require('http2');
9+
const { Http2Stream } = require('internal/http2/core');
10+
11+
// Errors should not be reported both in Http2ServerRequest
12+
// and Http2ServerResponse
13+
14+
let expected = null;
15+
16+
const server = h2.createServer(common.mustCall(function(req, res) {
17+
req.on('error', common.mustNotCall());
18+
res.on('error', common.mustNotCall());
19+
req.on('aborted', common.mustCall());
20+
res.on('aborted', common.mustNotCall());
21+
22+
res.write('hello');
23+
24+
expected = new Error('kaboom');
25+
res.stream.destroy(expected);
26+
server.close();
27+
}));
28+
29+
server.on('streamError', common.mustCall(function(err, stream) {
30+
assert.strictEqual(err, expected);
31+
assert.strictEqual(stream instanceof Http2Stream, true);
32+
}));
33+
34+
server.listen(0, common.mustCall(function() {
35+
const port = server.address().port;
36+
37+
const url = `http://localhost:${port}`;
38+
const client = h2.connect(url, common.mustCall(function() {
39+
const headers = {
40+
':path': '/foobar',
41+
':method': 'GET',
42+
':scheme': 'http',
43+
':authority': `localhost:${port}`,
44+
};
45+
const request = client.request(headers);
46+
request.on('data', common.mustCall(function(chunk) {
47+
// cause an error on the server side
48+
client.destroy();
49+
}));
50+
request.end();
51+
}));
52+
}));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Flags: --expose-http2
2+
'use strict';
3+
4+
const common = require('../common');
5+
if (!common.hasCrypto)
6+
common.skip('missing crypto');
7+
const http2 = require('http2');
8+
const assert = require('assert');
9+
const path = require('path');
10+
11+
const {
12+
HTTP2_HEADER_CONTENT_TYPE
13+
} = http2.constants;
14+
15+
const server = http2.createServer();
16+
server.on('stream', (stream) => {
17+
const file = path.join(process.cwd(), 'not-a-file');
18+
stream.respondWithFile(file, {
19+
[HTTP2_HEADER_CONTENT_TYPE]: 'text/plain'
20+
}, {
21+
onError(err) {
22+
common.expectsError({
23+
code: 'ENOENT',
24+
type: Error,
25+
message: `ENOENT: no such file or directory, open '${file}'`
26+
})(err);
27+
28+
stream.respond({ ':status': 404 });
29+
stream.end();
30+
},
31+
statCheck: common.mustNotCall()
32+
});
33+
});
34+
server.listen(0, () => {
35+
36+
const client = http2.connect(`http://localhost:${server.address().port}`);
37+
const req = client.request();
38+
39+
req.on('response', common.mustCall((headers) => {
40+
assert.strictEqual(headers[':status'], 404);
41+
}));
42+
req.on('data', common.mustNotCall());
43+
req.on('end', common.mustCall(() => {
44+
client.destroy();
45+
server.close();
46+
}));
47+
req.end();
48+
});

0 commit comments

Comments
 (0)