feat(http2): add SetHTTP2NextStreamID to customize the first client stream ID - #529
Merged
Conversation
…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
reviewed
Aug 28, 2026
imroc
left a comment
Owner
There was a problem hiding this comment.
Verified locally on top of 3bcd9d7 (master):
go build ./...,go vet ./...,go test ./...— all greengo test -race ./internal/http2/— green- PR CI (Go 1.25.x / 1.26.x) — green
Code review notes:
newClientConnappliesNextStreamIDbefore the priority-frame loop, so priority frames keep advancing the counter past claimed streams;initialStreamIDis recorded after the loop and defaults to 1, so existing behavior is unchanged.- Generalizing the
singleUseand GOAWAY first-stream checks from the literal1toinitialStreamIDis correct. Notably, this also fixes a pre-existing edge case: with priority frames claiming stream 3..13, the oldnextStreamID > 1check 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.Clonecarries 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Http2Connectionstarts
nextStreamIdat 3, while Go's default is 1. This value isobservable 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
NextStreamIDfield to the internal HTTP/2 transport, applied innewClientConnbefore the priority-frame loop, so priority frames stilladvance 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.
ClientConn.initialStreamIDand usesit for the "first stream on this connection" checks (
singleUseinidleStateLocked, the GOAWAY non-NO-error heuristic) instead of thehardcoded stream ID 1. Without this,
SetHTTP2NextStreamID(3)combined witha
Connection: closerequest would render the fresh single-use connectionunusable before its first request.
SetHTTP2NextStreamIDonTransport,Client, and as apackage-level wrapper (following the existing
SetHTTP2*precedent), andcarries the value over in
Transport.Clone.Backward compatibility
Fully backward compatible: the field's zero value keeps the current behavior
(
nextStreamIDstarts at 1), and no existing req functionality consumes thenew knob — it is a pure extension point, like the other
SetHTTP2*options.Testing
internal/http2unit tests (net.Pipe driven): default stays 1, odd valueapplied, 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).
reqpackage test: setter pass-through, invalid values ignored,Transport.Clonecarries the value.go vetclean;go test -race ./internal/...all green.