Skip to content

Commit aefee4f

Browse files
committed
quic: add proper error codes & messages for QUIC failures
Signed-off-by: Tim Perry <pimterry@gmail.com>
1 parent a9b98b2 commit aefee4f

9 files changed

Lines changed: 157 additions & 44 deletions

lib/internal/errors.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1687,14 +1687,14 @@ E('ERR_PERFORMANCE_INVALID_TIMESTAMP',
16871687
E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError);
16881688
E('ERR_PROXY_INVALID_CONFIG', '%s', Error);
16891689
E('ERR_PROXY_TUNNEL', '%s', Error);
1690-
E('ERR_QUIC_APPLICATION_ERROR', 'A QUIC application error occurred. %d [%s]', Error);
1690+
E('ERR_QUIC_APPLICATION_ERROR', '%s', Error);
16911691
E('ERR_QUIC_CONNECTION_FAILED', 'QUIC connection failed', Error);
16921692
E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error);
16931693
E('ERR_QUIC_OPEN_STREAM_FAILED', 'Failed to open QUIC stream', Error);
16941694
E('ERR_QUIC_STREAM_ABORTED', '%s', Error);
16951695
E('ERR_QUIC_STREAM_RESET',
16961696
'The QUIC stream was reset by the peer with error code %d', Error);
1697-
E('ERR_QUIC_TRANSPORT_ERROR', 'A QUIC transport error occurred. %d [%s]', Error);
1697+
E('ERR_QUIC_TRANSPORT_ERROR', '%s', Error);
16981698
E('ERR_QUIC_VERSION_NEGOTIATION_ERROR', 'The QUIC session requires version negotiation', Error);
16991699
E('ERR_REQUIRE_ASYNC_MODULE', function(filename, parentFilename) {
17001700
let message = 'require() cannot be used on an ESM ' +

lib/internal/quic/quic.js

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -673,10 +673,12 @@ setCallbacks({
673673
* @param {number} errorType
674674
* @param {number} code
675675
* @param {string} [reason]
676+
* @param {string} [errorName] Decoded TLS alert name when `code` is a
677+
* CRYPTO_ERROR; otherwise undefined.
676678
*/
677-
onSessionClose(errorType, code, reason) {
678-
debug('session close callback', errorType, code, reason);
679-
this[kOwner][kFinishClose](errorType, code, reason);
679+
onSessionClose(errorType, code, reason, errorName) {
680+
debug('session close callback', errorType, code, reason, errorName);
681+
this[kOwner][kFinishClose](errorType, code, reason, errorName);
680682
},
681683

682684
/**
@@ -968,21 +970,50 @@ class QuicError extends Error {
968970
}
969971
}
970972

971-
// Converts a raw QuicError array [type, code, reason] from C++ into a
972-
// proper Node.js Error object.
973+
// Build the human-readable message for an ERR_QUIC_TRANSPORT_ERROR or
974+
// ERR_QUIC_APPLICATION_ERROR. `errorName` is the symbolic name for
975+
// the wire code when known: either the OpenSSL-decoded TLS alert
976+
// (CRYPTO_ERROR; 0x100..0x1ff) or one of the named transport codes
977+
// from RFC 9000 (e.g. PROTOCOL_VIOLATION). Otherwise undefined.
978+
// `reason` is the peer-supplied UTF-8 reason string from the
979+
// CONNECTION_CLOSE / RESET_STREAM frame, often empty.
980+
function quicErrorMessage(prefix, errorCode, reason, errorName) {
981+
let msg = `${prefix} `;
982+
msg += errorName ? `${errorName} (${errorCode})` : `${errorCode}`;
983+
if (reason) msg += `: ${reason}`;
984+
return msg;
985+
}
986+
987+
function makeQuicError(ErrorClass, prefix, type, errorCode, reason, errorName) {
988+
const err = new ErrorClass(
989+
quicErrorMessage(prefix, errorCode, reason, errorName));
990+
err.errorCode = errorCode;
991+
err.type = type;
992+
if (reason) err.reason = reason;
993+
if (errorName) err.errorName = errorName;
994+
return err;
995+
}
996+
973997
function convertQuicError(error) {
974998
const type = error[0];
975999
const code = error[1];
9761000
const reason = error[2];
1001+
const errorName = error[3];
9771002
switch (type) {
9781003
case 'transport':
979-
return new ERR_QUIC_TRANSPORT_ERROR(code, reason);
1004+
return makeQuicError(ERR_QUIC_TRANSPORT_ERROR,
1005+
'QUIC transport error',
1006+
'transport', code, reason, errorName);
9801007
case 'application':
981-
return new ERR_QUIC_APPLICATION_ERROR(code, reason);
1008+
return makeQuicError(ERR_QUIC_APPLICATION_ERROR,
1009+
'QUIC application error',
1010+
'application', code, reason, errorName);
9821011
case 'version_negotiation':
9831012
return new ERR_QUIC_VERSION_NEGOTIATION_ERROR();
9841013
default:
985-
return new ERR_QUIC_TRANSPORT_ERROR(code, reason);
1014+
return makeQuicError(ERR_QUIC_TRANSPORT_ERROR,
1015+
'QUIC transport error',
1016+
'transport', code, reason, errorName);
9861017
}
9871018
}
9881019

@@ -3463,7 +3494,7 @@ class QuicSession {
34633494
* @param {number} code
34643495
* @param {string} [reason]
34653496
*/
3466-
[kFinishClose](errorType, code, reason) {
3497+
[kFinishClose](errorType, code, reason, errorName) {
34673498
// If code is zero, then we closed without an error. Yay! We can destroy
34683499
// safely without specifying an error.
34693500
if (code === 0n) {
@@ -3472,7 +3503,8 @@ class QuicSession {
34723503
return;
34733504
}
34743505

3475-
debug('finishing closing the session with an error', errorType, code, reason);
3506+
debug('finishing closing the session with an error',
3507+
errorType, code, reason, errorName);
34763508

34773509
// If the local side initiated this close with an error code (via
34783510
// close({ code })), this is an intentional shutdown; not an error.
@@ -3499,10 +3531,14 @@ class QuicSession {
34993531
// session would leak with `closed` hanging forever.
35003532
switch (errorType) {
35013533
case 0: /* Transport Error */
3502-
this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason));
3534+
this.destroy(makeQuicError(ERR_QUIC_TRANSPORT_ERROR,
3535+
'QUIC transport error',
3536+
'transport', code, reason, errorName));
35033537
break;
35043538
case 1: /* Application Error */
3505-
this.destroy(new ERR_QUIC_APPLICATION_ERROR(code, reason));
3539+
this.destroy(makeQuicError(ERR_QUIC_APPLICATION_ERROR,
3540+
'QUIC application error',
3541+
'application', code, reason, errorName));
35063542
break;
35073543
case 2: /* Version Negotiation Error */
35083544
this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR());
@@ -3511,7 +3547,9 @@ class QuicSession {
35113547
this.destroy();
35123548
break;
35133549
default:
3514-
this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason));
3550+
this.destroy(makeQuicError(ERR_QUIC_TRANSPORT_ERROR,
3551+
'QUIC transport error',
3552+
'transport', code, reason, errorName));
35153553
break;
35163554
}
35173555
}

src/quic/data.cc

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
#if HAVE_OPENSSL && HAVE_QUIC
22
#include "guard.h"
33
#ifndef OPENSSL_NO_QUIC
4-
#include "data.h"
54
#include <env-inl.h>
65
#include <memory_tracker-inl.h>
76
#include <ngtcp2/ngtcp2.h>
87
#include <node_sockaddr-inl.h>
8+
#include <openssl/ssl.h>
99
#include <string_bytes.h>
1010
#include <v8.h>
11+
#include "data.h"
1112
#include "defs.h"
1213
#include "util.h"
1314

@@ -346,6 +347,62 @@ std::optional<int> QuicError::get_crypto_error() const {
346347
return code() & ~NGTCP2_CRYPTO_ERROR;
347348
}
348349

350+
const char* QuicError::name() const {
351+
// CRYPTO_ERROR carries a TLS alert in its low byte (RFC 9001 sec. 4.8).
352+
// OpenSSL's SSL_alert_desc_string_long owns a stable string for every
353+
// alert it knows about; we filter out the "unknown" placeholder so the
354+
// JS side can present `errorName` as undefined for unrecognised alerts.
355+
if (auto alert = get_crypto_error()) {
356+
const char* n = SSL_alert_desc_string_long(*alert);
357+
if (n != nullptr && std::string_view(n) != "unknown") return n;
358+
return nullptr;
359+
}
360+
// Named transport-layer error codes from RFC 9000 sec. 20.1 (and the
361+
// RFC 9368 version-negotiation extension). Application error codes are
362+
// opaque to QUIC, so we only decode for transport.
363+
if (type() != Type::TRANSPORT) return nullptr;
364+
switch (code()) {
365+
case NGTCP2_NO_ERROR:
366+
return "NO_ERROR";
367+
case NGTCP2_INTERNAL_ERROR:
368+
return "INTERNAL_ERROR";
369+
case NGTCP2_CONNECTION_REFUSED:
370+
return "CONNECTION_REFUSED";
371+
case NGTCP2_FLOW_CONTROL_ERROR:
372+
return "FLOW_CONTROL_ERROR";
373+
case NGTCP2_STREAM_LIMIT_ERROR:
374+
return "STREAM_LIMIT_ERROR";
375+
case NGTCP2_STREAM_STATE_ERROR:
376+
return "STREAM_STATE_ERROR";
377+
case NGTCP2_FINAL_SIZE_ERROR:
378+
return "FINAL_SIZE_ERROR";
379+
case NGTCP2_FRAME_ENCODING_ERROR:
380+
return "FRAME_ENCODING_ERROR";
381+
case NGTCP2_TRANSPORT_PARAMETER_ERROR:
382+
return "TRANSPORT_PARAMETER_ERROR";
383+
case NGTCP2_CONNECTION_ID_LIMIT_ERROR:
384+
return "CONNECTION_ID_LIMIT_ERROR";
385+
case NGTCP2_PROTOCOL_VIOLATION:
386+
return "PROTOCOL_VIOLATION";
387+
case NGTCP2_INVALID_TOKEN:
388+
return "INVALID_TOKEN";
389+
case NGTCP2_APPLICATION_ERROR:
390+
return "APPLICATION_ERROR";
391+
case NGTCP2_CRYPTO_BUFFER_EXCEEDED:
392+
return "CRYPTO_BUFFER_EXCEEDED";
393+
case NGTCP2_KEY_UPDATE_ERROR:
394+
return "KEY_UPDATE_ERROR";
395+
case NGTCP2_AEAD_LIMIT_REACHED:
396+
return "AEAD_LIMIT_REACHED";
397+
case NGTCP2_NO_VIABLE_PATH:
398+
return "NO_VIABLE_PATH";
399+
case NGTCP2_VERSION_NEGOTIATION_ERROR:
400+
return "VERSION_NEGOTIATION_ERROR";
401+
default:
402+
return nullptr;
403+
}
404+
}
405+
349406
MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
350407
if ((type() == Type::TRANSPORT && code() == NGTCP2_NO_ERROR) ||
351408
(type() == Type::APPLICATION && code() == NGHTTP3_H3_NO_ERROR) ||
@@ -367,6 +424,7 @@ MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
367424
type_str,
368425
BigInt::NewFromUnsigned(env->isolate(), code()),
369426
Undefined(env->isolate()),
427+
Undefined(env->isolate()),
370428
};
371429

372430
// Note that per the QUIC specification, the reason, if present, is
@@ -380,6 +438,15 @@ MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
380438
return {};
381439
}
382440

441+
// Attach a human-readable name for known wire codes (RFC 9000 sec. 20.1
442+
// names and OpenSSL TLS alert descriptions for CRYPTO_ERROR). Unknown
443+
// codes leave the slot as undefined.
444+
if (const char* n = name()) {
445+
if (!node::ToV8Value(env->context(), n).ToLocal(&argv[3])) {
446+
return {};
447+
}
448+
}
449+
383450
return Array::New(env->isolate(), argv, arraysize(argv)).As<Value>();
384451
}
385452

src/quic/data.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,9 @@ class QuicError final : public MemoryRetainer {
231231
bool is_crypto_error() const;
232232
std::optional<int> get_crypto_error() const;
233233

234+
// Returns a human-readable name for this error if known, or nullptr
235+
const char* name() const;
236+
234237
// Note that since application errors are application-specific and we
235238
// don't know which application is being used here, it is possible that
236239
// the comparing two different QuicError instances from different applications

src/quic/session.cc

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3152,12 +3152,23 @@ void Session::EmitClose(const QuicError& error) {
31523152
Integer::New(env()->isolate(), static_cast<int>(error.type())),
31533153
BigInt::NewFromUnsigned(env()->isolate(), error.code()),
31543154
Undefined(env()->isolate()),
3155+
Undefined(env()->isolate()),
31553156
};
31563157
if (error.reason().length() > 0 &&
31573158
!ToV8Value(env()->context(), error.reason()).ToLocal(&argv[2])) {
31583159
return;
31593160
}
31603161

3162+
// Attach a human-readable name for known wire codes (RFC 9000 sec. 20.1
3163+
// names and OpenSSL TLS alert descriptions for CRYPTO_ERROR). Unknown
3164+
// codes leave the slot as undefined. See QuicError::name() for the
3165+
// matching path on stream-level errors.
3166+
if (const char* n = error.name()) {
3167+
if (!ToV8Value(env()->context(), n).ToLocal(&argv[3])) {
3168+
return;
3169+
}
3170+
}
3171+
31613172
MakeCallback(
31623173
BindingData::Get(env()).session_close_callback(), arraysize(argv), argv);
31633174

test/parallel/test-quic-session-close-error-code.mjs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,18 @@ const { listen, connect } = await import('../common/quic.mjs');
2929
const serverEndpoint = await listen(mustCall(async (serverSession) => {
3030
serverSession.onerror = mustCall((err) => {
3131
strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
32-
strictEqual(err.message.includes('42n'), true,
32+
strictEqual(err.message.includes('42'), true,
3333
'error message should contain the code');
3434
strictEqual(err.message.includes('client shutdown'), true,
3535
'error message should contain the reason');
36+
strictEqual(err.errorCode, 42n);
37+
strictEqual(err.type, 'application');
38+
strictEqual(err.reason, 'client shutdown');
3639
});
3740
await rejects(serverSession.closed, {
3841
code: 'ERR_QUIC_APPLICATION_ERROR',
42+
errorCode: 42n,
43+
reason: 'client shutdown',
3944
});
4045
serverGot.resolve();
4146
}));
@@ -71,8 +76,10 @@ const { listen, connect } = await import('../common/quic.mjs');
7176
const serverEndpoint = await listen(mustCall(async (serverSession) => {
7277
serverSession.onerror = mustCall((err) => {
7378
strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR');
74-
strictEqual(err.message.includes('1n'), true,
79+
strictEqual(err.message.includes('1'), true,
7580
'error message should contain the code');
81+
strictEqual(err.errorCode, 1n);
82+
strictEqual(err.type, 'transport');
7683
});
7784
await rejects(serverSession.closed, {
7885
code: 'ERR_QUIC_TRANSPORT_ERROR',
@@ -102,7 +109,10 @@ const { listen, connect } = await import('../common/quic.mjs');
102109
const serverEndpoint = await listen(mustCall(async (serverSession) => {
103110
serverSession.onerror = mustCall((err) => {
104111
strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
105-
strictEqual(err.message.includes('99n'), true);
112+
strictEqual(err.message.includes('99'), true);
113+
strictEqual(err.errorCode, 99n);
114+
strictEqual(err.type, 'application');
115+
strictEqual(err.reason, 'destroy with code');
106116
});
107117
await rejects(serverSession.closed, {
108118
code: 'ERR_QUIC_APPLICATION_ERROR',

test/parallel/test-quic-stream-destroy-emits-reset.mjs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,12 @@
1212
// code is the negotiated application's "internal error" code: for
1313
// the test fixture's non-h3 ALPN (`quic-test`) the C++
1414
// DefaultApplication reports `1n`, which propagates to the server
15-
// as `ERR_QUIC_APPLICATION_ERROR` carrying `1n` in its message.
15+
// as `ERR_QUIC_APPLICATION_ERROR` exposing `errorCode === 1n`.
1616

1717
import { hasQuic, skip, mustCall } from '../common/index.mjs';
1818
import assert from 'node:assert';
1919

20-
const { strictEqual, ok, rejects } = assert;
20+
const { strictEqual, rejects } = assert;
2121

2222
if (!hasQuic) {
2323
skip('QUIC is not enabled');
@@ -35,10 +35,8 @@ const serverEndpoint = await listen(mustCall((serverSession) => {
3535
// fired with the expected code.
3636
stream.onreset = mustCall((err) => {
3737
strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
38-
// The DefaultApplication's internal error code is 0x1n, which
39-
// util.format renders as `1n` (BigInt) in the message text.
40-
ok(err.message.includes('1n'),
41-
`expected '1n' in message, got: ${err.message}`);
38+
// The DefaultApplication's internal error code is 0x1n.
39+
strictEqual(err.errorCode, 1n);
4240
serverResetSeen.resolve();
4341
});
4442
});

test/parallel/test-quic-stream-destroy-options-code.mjs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { hasQuic, skip, mustCall } from '../common/index.mjs';
1414
import assert from 'node:assert';
1515

16-
const { strictEqual, ok, rejects } = assert;
16+
const { strictEqual, rejects } = assert;
1717

1818
if (!hasQuic) {
1919
skip('QUIC is not enabled');
@@ -27,8 +27,7 @@ const serverEndpoint = await listen(mustCall((serverSession) => {
2727
serverSession.onstream = mustCall((stream) => {
2828
stream.onreset = mustCall((err) => {
2929
strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
30-
ok(err.message.includes('66n'),
31-
`expected '66n' in message, got: ${err.message}`);
30+
strictEqual(err.errorCode, 66n);
3231
serverResetSeen.resolve();
3332
});
3433
});

test/parallel/test-quic-stream-writer-fail-error-code.mjs

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,8 @@
1717
// the QuicError fast path.
1818
//
1919
// The peer-side observation goes through `stream.onreset(err)` where
20-
// `err` is `ERR_QUIC_APPLICATION_ERROR` carrying the wire code in its
21-
// message string. We extract the code via regex; once
22-
// `ERR_QUIC_APPLICATION_ERROR` exposes the numeric code as a property
23-
// (a planned follow-up), this test can switch to direct property
24-
// access.
20+
// `err` is `ERR_QUIC_APPLICATION_ERROR` exposing the wire code on
21+
// `err.errorCode` (a BigInt).
2522

2623
import { hasQuic, skip, mustCall } from '../common/index.mjs';
2724
import assert from 'node:assert';
@@ -35,19 +32,9 @@ if (!hasQuic) {
3532
const { listen, connect } = await import('../common/quic.mjs');
3633
const { QuicError } = await import('node:quic');
3734

38-
// Extract the numeric wire code from an ERR_QUIC_APPLICATION_ERROR
39-
// message of the form
40-
// "A QUIC application error occurred. <code>n [<reason>]"
41-
// where the trailing `n` on the code is the BigInt formatting from
42-
// `util.format('%d', bigint)`. RESET_STREAM frames do not carry a
43-
// reason string, so the bracketed value is typically `undefined`.
4435
function wireCodeOf(err) {
4536
strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
46-
const match = err.message.match(/A QUIC application error occurred\. (\d+)n /);
47-
if (!match) {
48-
throw new Error(`Could not extract code from message: ${err.message}`);
49-
}
50-
return BigInt(match[1]);
37+
return err.errorCode;
5138
}
5239

5340
// Server: capture the next two streams. Each stream receives an

0 commit comments

Comments
 (0)