quic: convert incoming :status header to number
Project: nodejs/node | Language: JavaScript | Concepts: api-design, backwards-compatibility, testing-strategy | Difficulty: mid
Link: https://github.com/nodejs/node/pull/63589
The problem
When using Node.js's experimental QUIC/HTTP3 implementation, incoming HTTP response headers exposed the :status pseudo-header as a string (e.g., '200'). In contrast, HTTP/2 code in Node.js exposes :status as a number (e.g., 200). This inconsistency made it awkward to write code that works with both protocols, and could lead to subtle bugs or extra work when porting code between HTTP/2 and HTTP/3.
Why it's non-trivial
A naive fix might just coerce the value to a number wherever it's used, but:
- The conversion must only apply to incoming headers, not outgoing ones (which should remain flexible).
- The fix needs to be consistent everywhere incoming headers are parsed and exposed (initial headers, informational headers, etc.).
- Tests and documentation must be updated to reflect the new, consistent behavior.
The approach
1. Update documentation to clarify the new behavior:
- For incoming headers, the `:status` pseudo-header is converted to
- a `number`, matching HTTP/2 behavior. After the callback returns,
- the same object is also accessible via [`stream.headers`][].
This change informs users that :status is now a number for incoming headers, matching HTTP/2.
2. Change the header parsing logic to convert :status to a number:
- if (block[pairs[n]] !== undefined) {
- if (ArrayIsArray(block[pairs[n]])) {
- ArrayPrototypePush(block[pairs[n]], pairs[n + 1]);
+ const name = pairs[n];
+ let value = pairs[n + 1];
+ // Match HTTP/2 behavior: incoming :status is exposed as a number.
+ if (name === ':status')
+ value |= 0;
+ if (block[name] !== undefined) {
+ if (ArrayIsArray(block[name])) {
+ ArrayPrototypePush(block[name], value);
} else {
- block[pairs[n]] = [block[pairs[n]], pairs[n + 1]];
+ block[name] = [block[name], value];
}
} else {
- block[pairs[n]] = pairs[n + 1];
+ block[name] = value;
}
This snippet is the core logic change. When parsing header pairs, if the header name is :status, it coerces the value to a number using the bitwise OR trick (value |= 0). This ensures all incoming :status headers are numbers, not strings.
3. Update all relevant tests to expect a numeric :status:
- assert.strictEqual(headers[':status'], '200');
+ assert.strictEqual(headers[':status'], 200);
This change appears throughout the test suite, ensuring tests now check for a numeric value, not a string. This validates the new behavior and prevents regressions.
4. Add a new test specifically for status code type:
+diff --git a/test/parallel/test-quic-h3-status-code-type.mjs b/test/parallel/test-quic-h3-status-code-type.mjs
+new file mode 100644
A new test file is introduced (details omitted in the diff) to explicitly check that various status codes (e.g., 200, 204, 404) are parsed as numbers.
From the review thread
pimterry: @hallss93 Looks like the commit message here is failing the linting as it's not signed off, you'll need to update that with
git commit --amend -sand then force push.
trivikr: @hallss93 Can you please rebase onto main and remove the merge commit?
Instructions: https://github.com/nodejs/node/blob/main/doc/contributing/pull-requests.md#step-5-rebase
Takeaways
- APIs should be consistent across similar protocols: When exposing similar concepts (like HTTP headers) across different protocol implementations, match types and behaviors to reduce friction for users and avoid subtle bugs.
- Centralize data normalization at the parsing layer: Apply type conversions (like string-to-number) as early as possible, ideally where data is first parsed, so all downstream consumers see the correct type.
- Update tests and docs to match API changes: Whenever you change the type or semantics of a public API, update both the documentation and all relevant tests to reflect and enforce the new contract.