quic: reset rejected HTTP/3 request streams with H3_REQUEST_REJECTED
Project: nodejs/node | Language: JavaScript, C++ | Concepts: networking, api-design, error-handling | Difficulty: senior
Link: https://github.com/nodejs/node/pull/65442
The problem
When an HTTP/3 request stream arrives and there is no application-level consumer to handle it (for example, the server is not configured to accept new streams), Node.js would previously just destroy the stream. This did not communicate to the remote peer that the request was rejected without being processed, which is required by the HTTP/3 RFC (9114 §4.1.1) via the H3_REQUEST_REJECTED error code.
Why it's non-trivial
A naive fix might simply destroy the stream or always use a generic error code, but the HTTP/3 protocol requires a specific error code (H3_REQUEST_REJECTED) to signal that a request was rejected without application processing. Other protocols over QUIC may not have this semantic, so the implementation must be protocol-aware and only send this code when appropriate, without breaking non-HTTP/3 applications.
The approach
1. Detecting when a stream is rejected and choosing the right error code:
In the QUIC session code, when a new stream arrives but there is no consumer, the code now checks if a protocol-specific "request rejected" code is available and uses it:
- stream.destroy();
+ // When the negotiated application defines a "request rejected" code
+ // (HTTP/3: H3_REQUEST_REJECTED), reset the stream with it so the peer
+ // learns the request was not processed (RFC 9114 section 4.1.1).
+ // Other applications have no such semantic and are torn down as before.
+ const rejectedCode = getQuicSessionState(this).requestRejectedCode;
+ if (getQuicSessionState(this).streamCallbacksSupported === 1) {
+ stream.destroy(undefined, { code: rejectedCode });
+ } else {
+ stream.destroy();
+ }
Annotation:
- If the application protocol defines a "request rejected" code (like HTTP/3), the stream is destroyed with that code.
- If not, the stream is destroyed as before (no special code).
- This ensures protocol compliance and backward compatibility.
2. Surfacing the "request rejected" code from the protocol implementation to JavaScript:
The session state is extended to carry the new error code:
+ get requestRejectedCode() {
+ const handle = this.#handle;
+ if (handle === undefined) return undefined;
+ return DataViewPrototypeGetBigUint64(
+ handle, this.#offset + IDX_STATE_SESSION_REQUEST_REJECTED_CODE, kIsLittleEndian);
+ }
Annotation:
- This getter fetches the protocol-specific error code from native state, making it available to JavaScript.
3. Defining the new error code in the protocol abstraction:
The QUIC application interface is extended so each protocol can define its own "request rejected" code:
+ // The "request rejected" code is sent on RESET_STREAM when an incoming
+ // request stream is rejected without any application processing (e.g.
+ // the session has no consumer for it), so the peer learns the request
+ // was not processed. For HTTP/3 this is NGHTTP3_H3_REQUEST_REJECTED
+ // (0x10b); other applications have no such semantic and reuse the
+ // "no error" code.
+ virtual error_code GetRequestRejectedCode() const = 0;
Annotation:
- This virtual method allows each protocol to specify what code to use when rejecting a request.
4. Implementing the error code for HTTP/3 and fallback for raw QUIC:
For HTTP/3:
+ error_code GetRequestRejectedCode() const override {
+ return NGHTTP3_H3_REQUEST_REJECTED;
+ }
For raw QUIC (no HTTP/3 semantics):
+ // Raw QUIC has no "request rejected" semantic; reuse the no-error code.
+ error_code GetRequestRejectedCode() const override {
+ return GetNoErrorCode();
+ }
Annotation:
- HTTP/3 uses the correct RFC-defined code.
- Raw QUIC just uses a generic "no error" code, preserving compatibility.
5. Wiring the new code through session setup:
When the session protocol is set up, the new code is stored in the session state:
+ impl_->state()->request_rejected_code = app->GetRequestRejectedCode();
Annotation:
- This ensures the right code is always available in the session state for use in stream resets.
6. Adding a test to verify the behavior:
A new test ensures that when a request is rejected, the client receives the H3_REQUEST_REJECTED code:
+stream.onreset = mustCall((err) => {
+ assert.strictEqual(err.code, 'ERR_QUIC_APPLICATION_ERROR');
+ assert.strictEqual(err.errorCode, H3_REQUEST_REJECTED);
+ reset.resolve();
+});
Annotation:
- The test verifies that the correct error code is surfaced to the client when a stream is rejected.
From the review thread
No notable review discussion on this PR.
Takeaways
- When implementing protocol features, ensure error signaling matches the protocol specification, not just generic error handling.
- Design extensible abstractions so protocol-specific behaviors (like error codes) can be surfaced without breaking other implementations.
- Always add targeted tests for new protocol behaviors, especially when they affect interoperability.