Skip to content

feat(http2): add SetHTTP2NextStreamID to customize the first client stream ID - #529

Merged
imroc merged 1 commit into
imroc:masterfrom
RonaldinhoL:master
Aug 29, 2026
Merged

feat(http2): add SetHTTP2NextStreamID to customize the first client stream ID#529
imroc merged 1 commit into
imroc:masterfrom
RonaldinhoL:master

Conversation

@RonaldinhoL

Copy link
Copy Markdown
Contributor

Motivation

RFC 9113 §5.1.1 allows a client to pick any odd 31-bit stream ID for its first
client-initiated stream, and real-world clients differ here:
OkHttp's Http2Connection
starts nextStreamId at 3, while Go's default is 1. This value is
observable on the wire and is part of a client's HTTP/2 fingerprint, so
fingerprint-oriented users of req (e.g. client impersonation) currently cannot
replicate OkHttp's behavior: req already exposes SETTINGS, connection flow,
header priority and priority frames, but the initial stream ID is hardcoded.

Note this cannot be emulated with the existing SetHTTP2PriorityFrames:
those frames are actually sent on the wire on the claimed stream IDs, which
OkHttp does not do — faking the starting ID that way produces a different
fingerprint, not a correct one.

What this PR does

  • Adds a NextStreamID field to the internal HTTP/2 transport, applied in
    newClientConn before the priority-frame loop, so priority frames still
    advance the counter past the stream IDs they claim (e.g. a Firefox-style
    priority tree claiming streams 3..13 yields 15). Even values and values
    beyond 31 bits are ignored defensively.
  • Records the handshake-time counter as ClientConn.initialStreamID and uses
    it for the "first stream on this connection" checks (singleUse in
    idleStateLocked, the GOAWAY non-NO-error heuristic) instead of the
    hardcoded stream ID 1. Without this, SetHTTP2NextStreamID(3) combined with
    a Connection: close request would render the fresh single-use connection
    unusable before its first request.
  • Exposes SetHTTP2NextStreamID on Transport, Client, and as a
    package-level wrapper (following the existing SetHTTP2* precedent), and
    carries the value over in Transport.Clone.

Backward compatibility

Fully backward compatible: the field's zero value keeps the current behavior
(nextStreamID starts at 1), and no existing req functionality consumes the
new knob — it is a pure extension point, like the other SetHTTP2* options.

Testing

  • internal/http2 unit tests (net.Pipe driven): default stays 1, odd value
    applied, even value ignored, >31-bit value ignored, priority frames advance
    past claimed streams, custom base + priority frames, and single-use
    connection regression tests (first request accepted, second rejected).
  • Outer req package test: setter pass-through, invalid values ignored,
    Transport.Clone carries the value.
  • go vet clean; go test -race ./internal/... all green.

…tream ID

RFC 9113 allows a client to pick any odd stream ID for its first
client-initiated stream, and real-world clients differ here: OkHttp's
Http2Connection starts nextStreamId at 3, while Go's default is 1.
This value is observable on the wire and is part of a client's HTTP/2
fingerprint.

Add a NextStreamID field to the internal HTTP/2 transport, applied in
newClientConn before the priority-frame loop so that priority frames
still advance the counter past the stream IDs they claim (e.g. a
Firefox-style priority tree claiming streams 3..13 yields 15). Even
values and values beyond 31 bits are ignored defensively.

Also record the handshake-time counter as ClientConn.initialStreamID
and use it for the "first stream on this connection" checks
(singleUse idleState, GOAWAY non-NO-error heuristic) instead of the
hardcoded stream ID 1, which would misbehave once the counter starts
at a different value.

Expose the setting as SetHTTP2NextStreamID on Client, Transport and as
a package-level wrapper, carry it over in Transport.Clone, and cover
the behavior with unit tests at both layers.

@imroc imroc left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified locally on top of 3bcd9d7 (master):

  • go build ./..., go vet ./..., go test ./... — all green
  • go test -race ./internal/http2/ — green
  • PR CI (Go 1.25.x / 1.26.x) — green

Code review notes:

  • newClientConn applies NextStreamID before the priority-frame loop, so priority frames keep advancing the counter past claimed streams; initialStreamID is recorded after the loop and defaults to 1, so existing behavior is unchanged.
  • Generalizing the singleUse and GOAWAY first-stream checks from the literal 1 to initialStreamID is correct. Notably, this also fixes a pre-existing edge case: with priority frames claiming stream 3..13, the old nextStreamID > 1 check made a fresh single-use connection immediately unusable.
  • Validation is applied in both the public setter (even / >31-bit values ignored) and defensively in newClientConn; Transport.Clone carries the field over. Zero value keeps default behavior, so this is fully backward compatible.
  • RFC 9113 §5.1.1 permits starting client stream IDs at any odd value (OkHttp starts at 3), so the wire behavior is protocol-legal.

Since this modifies internal/http2/transport.go (vendored x/net/http2), leaving the merge decision for human review per the project's policy for modified-code changes.

@imroc
imroc merged commit c99ed5a into imroc:master Aug 29, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants