§13.2
Chapter 13 · Implementation Guidance and Interoperability Testing

§13.2Interoperability Pitfalls with HTTP3 and QPACK Settings

RFC 9114RFC 9204

Learning objective. Understand the interoperability pitfalls that break two correctly-configured HTTP/3 stacks: the "ignore the unknown" rule and the reserved (GREASE) code points that test it, the HTTP/2-inherited code points that must be rejected, the SETTINGS ordering rules, and the QPACK default-zero trap.

The one rule: be liberal in what you accept #

Almost every HTTP/3 interop bug is a violation of one principle: an implementation that errors on something it should have ignored. The spec is emphatic: "Implementations MUST ignore unknown or unsupported values in all extensible protocol elements... any of these extension points can be safely used by extensions without prior arrangement or negotiation" ([RFC 9114 §9]). An unknown frame type on a request stream is read-and-skipped by its Length, exactly as §5.1 described; an unknown setting is ignored ([RFC 9114 §7.2.4]); an unknown unidirectional stream type is discarded ([RFC 9114 §6.2.3]). A stack that instead closes the connection on anything unfamiliar cannot interop with a peer that uses a newer extension — or one that is merely testing the rule.

But the rule is not "ignore everything." A received code point is handled in one of three ways, and getting the split wrong is the pitfall:

yes

yes

no

no

yes

no

received frame type X on a stream

known HTTP/3 type?

allowed on this stream, in a valid order?

process it

H3_FRAME_UNEXPECTED
(e.g. DATA before HEADERS)

an HTTP/2-only type?
0x02 0x06 0x08 0x09

H3_FRAME_UNEXPECTED
reserved from HTTP/2

IGNORE / skip it
unknown or reserved 0x1f·N + 0x21

Fig. 13.2-1How a receiver must handle a frame type — the crux of HTTP/3 interop. A known type in a valid location is processed; an HTTP/2-only type (0x02, 0x06, 0x08, 0x09) or a known type in the wrong place is an H3_FRAME_UNEXPECTED error; and anything else — unknown or a reserved 0x1f·N+0x21 value — MUST be ignored and skipped. Erroring in the third branch is the classic interop bug.RFC 9114 §9, §7.2.8, §4.1

GREASE: deliberately sending the unknown #

Because "ignore the unknown" is only tested when something unknown actually arrives, QUIC and HTTP/3 reserve code points whose purpose is to be sent and ignored: the mechanism the community calls GREASE (from RFC 8701; the QUIC RFCs themselves only say "reserved to exercise the requirement that unknown types be ignored"). The reserved values follow a formula that differs by code space (constants.md §15):

  • HTTP/3 frame types, settings, and stream types: 0x1f·N + 0x21 ([RFC 9114 §7.2.8, §7.2.4.1, §6.2.3]).
  • QUIC transport parameters: 0x1f·N + 0x1b — a different base ([RFC 9000 §18.1]); mixing the two up is itself a pitfall.

Crucially, an endpoint SHOULD proactively send a reserved setting, "Endpoints SHOULD include at least one such setting in their SETTINGS frame" ([RFC 9114 §7.2.4.1]), and MAY send reserved frames. The point is offensive testing: if your peer ships a GREASE setting and your stack errors, the bug is yours, and you would have failed against any real deployment that greases. Implement the ignore rule and send GREASE yourself.

The opposite pitfall: HTTP/2 code points you must reject #

The tolerant rule has a sharp exception. HTTP/3 reused some of HTTP/2's wire concepts but assigned them new code points, so the old HTTP/2 code points are poison: "Frame types that were used in HTTP/2 where there is no corresponding HTTP/3 frame have also been reserved. These frame types MUST NOT be sent, and their receipt MUST be treated as a connection error of type H3_FRAME_UNEXPECTED" ([RFC 9114 §7.2.8]). The reserved-from-HTTP/2 frame types are 0x02 (PRIORITY), 0x06 (PING), 0x08 (WINDOW_UPDATE), and 0x09 (CONTINUATION) ([RFC 9114 §11.2.1]); the reserved settings are 0x00, 0x02, 0x03, 0x04, 0x05 ([RFC 9114 §11.2.2]), each a H3_SETTINGS_ERROR on receipt. A stack ported from HTTP/2 that leaves those code points active is a classic interop failure. It sends a frame the peer must reject.

SETTINGS ordering and critical streams #

Several MUST-level rules around SETTINGS and the critical streams trip implementations ([RFC 9114 §6.2.1, §7.2.4]):

  • SETTINGS MUST be the first frame on the control stream; any other first frame is H3_MISSING_SETTINGS.
  • A second SETTINGS frame is H3_FRAME_UNEXPECTED; a duplicate identifier within one frame MAY be H3_SETTINGS_ERROR.
  • The control stream and the QPACK encoder/decoder streams are critical: a second instance is H3_STREAM_CREATION_ERROR, and closing one is H3_CLOSED_CRITICAL_STREAM ([RFC 9204 §4.2], §6.1).

These are the errors that show up when two stacks establish a connection but the control-plane handshake falls apart in the first few frames.

The QPACK default-zero trap #

The most common QPACK interop bug is a defaulting mistake. Both SETTINGS_QPACK_MAX_TABLE_CAPACITY and SETTINGS_QPACK_BLOCKED_STREAMS default to 0 ([RFC 9204 §5], constants.md §11). So until a peer has explicitly advertised a non-zero capacity, the encoder "MUST NOT insert entries into the dynamic table and MUST NOT send any encoder instructions" ([RFC 9204 §3.2.3]). An encoder that assumes a non-zero default and starts using the dynamic table (or sets a capacity above the advertised maximum) is a QPACK_ENCODER_STREAM_ERROR ([RFC 9204 §4.3.1]); one that lets more streams block than the peer's SETTINGS_QPACK_BLOCKED_STREAMS promised is a QPACK_DECOMPRESSION_FAILED ([RFC 9204 §2.1.2]). The safe default is static-only until told otherwise (§6.4). It is why "start conservative" (§5.2) is both a performance and an interop recommendation.

Worked example: a stack that fails against a grease-ing peer #

Two stacks, both "working" in isolation, fail to interop. A capture (§11.3) shows the client's SETTINGS frame carries a reserved identifier (0x1f·N + 0x21) alongside its real settings. It greases, as it should. The server, ported from an HTTP/2 codebase, looks the identifier up, does not find it, and closes the connection with H3_SETTINGS_ERROR — treating unknown as invalid, the exact inversion of the rule. The fix is one branch of Figure 13.2-1: an unknown setting is ignored, not an error. The same server would break against any browser, all of which grease. The lesson generalizes: test against a peer that deliberately sends reserved code points, because a stack that only ever talks to itself never exercises the ignore path.

Note

The two halves of the rule are easy to state and easy to get backwards: ignore what you don't recognize, but reject the HTTP/2 code points you do. The asymmetry exists because an unknown code point might be a legitimate future extension (so tolerate it), while an HTTP/2-reserved code point can only be a bug or a confused peer (so reject it). An implementation that reverses this (erroring on unknowns and silently accepting HTTP/2 frames) is wrong in both directions at once, and it is a surprisingly common shape of bug in stacks derived from HTTP/2 code. The decision tree in Figure 13.2-1 is worth encoding directly as the frame dispatcher's structure.

In practice

editorial Two habits prevent most HTTP/3 interop failures. First, grease your own output (send a reserved setting and occasionally a reserved frame) so your test traffic exercises peers' ignore paths and your own code never assumes a closed set of code points. Second, run against the community interop test suite (the quic-interop-runner matrix, a community project not part of the RFCs) and against real browsers, which grease aggressively; a green light there catches the HTTP/2-code-point and default-zero traps before users do. When a connection dies in the first few frames, suspect the control-plane rules (SETTINGS placement, a reserved code point, a critical-stream error) before you suspect the data path.

Takeaways #

HTTP/3 interop rests on one asymmetric rule: ignore unknown and reserved (GREASE) code points, but reject the HTTP/2-inherited ones (frames 0x02/0x06/0x08/0x09, settings 0x00/0x02–0x05) as H3_FRAME_UNEXPECTED / H3_SETTINGS_ERROR. Reserved code points follow 0x1f·N+0x21 in HTTP/3 and 0x1f·N+0x1b for transport parameters, and endpoints SHOULD send them to test peers. SETTINGS must be the first control-stream frame, critical streams must stay open, and QPACK's capacities default to zero, so the dynamic table is off until advertised. Validating these behaviors under stress, with malformed and adversarial input, is §13.3.