unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
api-designbackwards-compatibilitytesting-strategy

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 -s and 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.