diff --git a/client.go b/client.go index d67b7fbb..36c5fcec 100644 --- a/client.go +++ b/client.go @@ -1018,6 +1018,16 @@ func (c *Client) SetHTTP2PriorityFrames(frames ...http2.PriorityFrame) *Client { return c } +// SetHTTP2NextStreamID sets the stream ID of the first client-initiated +// stream on new HTTP/2 connections (default 1). Some clients use a +// different starting value (e.g. OkHttp starts at 3), which is part of +// their HTTP/2 fingerprint. The value must be odd and fit into 31 bits +// (RFC 9113); invalid values are ignored. +func (c *Client) SetHTTP2NextStreamID(id uint32) *Client { + c.Transport.SetHTTP2NextStreamID(id) + return c +} + // SetCommonContentType set the `Content-Type` header for requests fired // from the client. func (c *Client) SetCommonContentType(ct string) *Client { diff --git a/client_wrapper.go b/client_wrapper.go index 987ed04a..e6975649 100644 --- a/client_wrapper.go +++ b/client_wrapper.go @@ -471,6 +471,12 @@ func SetHTTP2PriorityFrames(frames ...http2.PriorityFrame) *Client { return defaultClient.SetHTTP2PriorityFrames(frames...) } +// SetHTTP2NextStreamID is a global wrapper methods which delegated +// to the default client's Client.SetHTTP2NextStreamID. +func SetHTTP2NextStreamID(id uint32) *Client { + return defaultClient.SetHTTP2NextStreamID(id) +} + // SetHTTP2MaxHeaderListSize is a global wrapper methods which delegated // to the default client's Client.SetHTTP2MaxHeaderListSize. func SetHTTP2MaxHeaderListSize(max uint32) *Client { diff --git a/internal/http2/transport.go b/internal/http2/transport.go index 4caf07e5..de58c1b6 100644 --- a/internal/http2/transport.go +++ b/internal/http2/transport.go @@ -145,6 +145,15 @@ type Transport struct { HeaderPriority http2.PriorityParam PriorityFrames []http2.PriorityFrame + // NextStreamID is the stream ID assigned to the first client-initiated + // stream on each new connection (default 1; client stream IDs are odd + // per RFC 9113). Some real-world clients use a different starting + // value — OkHttp, for example, starts at 3 — and the value is + // observable on the wire as part of the client's HTTP/2 fingerprint. + // Must be odd; even values are ignored. If PriorityFrames are also + // configured, the counter advances past the stream IDs they claim. + NextStreamID uint32 + connPoolOnce sync.Once connPoolOrDef ClientConnPool // non-nil version of ConnPool } @@ -224,6 +233,13 @@ type ClientConn struct { streams map[uint32]*clientStream // client-initiated streamsReserved int // incr by ReserveNewRequest; decr on RoundTrip nextStreamID uint32 + // initialStreamID is the value of nextStreamID right after the + // connection handshake, i.e. the stream ID of the first request stream + // on this connection. It records the starting point configured via + // Transport.NextStreamID and/or advanced by priority frames, so that + // "first stream" checks (singleUse, GOAWAY heuristics) keep working + // when the counter does not start at 1. + initialStreamID uint32 pendingRequests int // requests blocked and waiting to be sent because len(streams) == maxConcurrentStreams pings map[[8]byte]chan struct{} // in flight ping data to notification channel br *bufio.Reader @@ -835,11 +851,38 @@ func (t *Transport) newClientConn(c net.Conn, singleUse bool) (*ClientConn, erro } cc.fr.WriteWindowUpdate(0, connFlow) + // Apply the configured initial stream ID (e.g. OkHttp starts its first + // request stream at 3 instead of 1). + // + // The guards: + // - t.NextStreamID > 1: 0 means the option was never set (zero value) + // and 1 equals the default already assigned above, so only values + // beyond 1 need to override it. + // - t.NextStreamID%2 == 1: client-initiated stream IDs must be odd + // (RFC 9113 §5.1.1); even values are protocol violations and are + // ignored defensively. + // - t.NextStreamID <= math.MaxInt32: stream IDs are 31-bit + // (RFC 9113 §5.1.1); larger values would make the connection + // unable to take any request. + // + // This must run before the priority-frame loop below: priority frames + // claim their stream IDs and push the counter past each of them + // (e.g. Firefox claims streams 3..13, so the first request stream + // becomes 15), and that advancement must not be overwritten here. + if t.NextStreamID > 1 && t.NextStreamID%2 == 1 && t.NextStreamID <= math.MaxInt32 { + cc.nextStreamID = t.NextStreamID + } + for _, p := range t.PriorityFrames { cc.fr.WritePriority(p.StreamID, p.PriorityParam) cc.nextStreamID = p.StreamID + 2 } + // Record the handshake-time starting point of the stream ID counter, + // so that "first stream on this connection" checks keep working when + // it is not 1 (custom NextStreamID and/or priority frames above). + cc.initialStreamID = cc.nextStreamID + cc.inflow.init(int32(connFlow) + initialWindowSize) cc.bw.Flush() if cc.werr != nil { @@ -903,7 +946,7 @@ func (cc *ClientConn) setGoAway(f *GoAwayFrame) { // without doing so. Either way, leave the stream alone for now. continue } - if streamID == 1 && cc.goAway.ErrCode != ErrCodeNo { + if streamID == cc.initialStreamID && cc.goAway.ErrCode != ErrCodeNo { // Don't retry the first stream on a connection if we get a non-NO error. // If the server is sending an error on a new connection, // retrying the request on a new one probably isn't going to work. @@ -986,7 +1029,7 @@ func (cc *ClientConn) idleState() clientConnIdleState { } func (cc *ClientConn) idleStateLocked() (st clientConnIdleState) { - if cc.singleUse && cc.nextStreamID > 1 { + if cc.singleUse && cc.nextStreamID > cc.initialStreamID { return } var maxConcurrentOkay bool diff --git a/internal/http2/transport_nextstreamid_test.go b/internal/http2/transport_nextstreamid_test.go new file mode 100644 index 00000000..1a94457a --- /dev/null +++ b/internal/http2/transport_nextstreamid_test.go @@ -0,0 +1,102 @@ +package http2 + +import ( + "io" + "net" + "testing" + + reqhttp2 "github.com/imroc/req/v3/http2" +) + +// newPipeClientConn drives newClientConn over a net.Pipe whose peer merely +// drains the client's preface/SETTINGS/priority frames without sending any +// server response. +func newPipeClientConn(t *testing.T, tr *Transport, singleUse bool) *ClientConn { + t.Helper() + c1, c2 := net.Pipe() + t.Cleanup(func() { c1.Close() }) + t.Cleanup(func() { c2.Close() }) + go io.Copy(io.Discard, c2) + + cc, err := tr.newClientConn(c1, singleUse) + if err != nil { + t.Fatalf("newClientConn: %v", err) + } + t.Cleanup(func() { cc.Close() }) + return cc +} + +func TestNextStreamID(t *testing.T) { + t.Run("default is 1", func(t *testing.T) { + if got := newPipeClientConn(t, &Transport{}, false).nextStreamID; got != 1 { + t.Errorf("nextStreamID = %d, want 1", got) + } + }) + + t.Run("custom odd value applied", func(t *testing.T) { + // OkHttp starts its first request stream at 3. + tr := &Transport{NextStreamID: 3} + if got := newPipeClientConn(t, tr, false).nextStreamID; got != 3 { + t.Errorf("nextStreamID = %d, want 3", got) + } + }) + + t.Run("even value ignored", func(t *testing.T) { + // Client stream IDs must be odd (RFC 9113); even values are ignored. + tr := &Transport{NextStreamID: 4} + if got := newPipeClientConn(t, tr, false).nextStreamID; got != 1 { + t.Errorf("nextStreamID = %d, want 1", got) + } + }) + + t.Run("value beyond 31 bits ignored", func(t *testing.T) { + // Stream IDs are 31-bit (RFC 9113); larger values are ignored. + tr := &Transport{NextStreamID: 1<<31 + 1} + if got := newPipeClientConn(t, tr, false).nextStreamID; got != 1 { + t.Errorf("nextStreamID = %d, want 1", got) + } + }) + + t.Run("priority frames advance past claimed streams", func(t *testing.T) { + // Firefox-style priority tree: placeholder streams 3..13, so the + // first request stream must be 15. + tr := &Transport{ + PriorityFrames: []reqhttp2.PriorityFrame{ + {StreamID: 3, PriorityParam: reqhttp2.PriorityParam{Weight: 200}}, + {StreamID: 13, PriorityParam: reqhttp2.PriorityParam{Weight: 240}}, + }, + } + if got := newPipeClientConn(t, tr, false).nextStreamID; got != 15 { + t.Errorf("nextStreamID = %d, want 15", got) + } + }) + + t.Run("custom base with priority frames still advances", func(t *testing.T) { + tr := &Transport{ + NextStreamID: 5, + PriorityFrames: []reqhttp2.PriorityFrame{ + {StreamID: 7, PriorityParam: reqhttp2.PriorityParam{Weight: 100}}, + }, + } + if got := newPipeClientConn(t, tr, false).nextStreamID; got != 9 { + t.Errorf("nextStreamID = %d, want 9", got) + } + }) + + t.Run("singleUse conn stays usable with custom NextStreamID", func(t *testing.T) { + // Regression test: a singleUse connection must accept its first + // request even when the stream ID counter does not start at 1. + cc := newPipeClientConn(t, &Transport{NextStreamID: 3}, true) + if st := cc.idleState(); !st.canTakeNewRequest { + t.Error("singleUse conn with NextStreamID=3 cannot take its first request") + } + }) + + t.Run("singleUse conn rejects second request", func(t *testing.T) { + cc := newPipeClientConn(t, &Transport{NextStreamID: 3}, true) + cc.nextStreamID += 2 // simulate one request stream allocated + if st := cc.idleState(); st.canTakeNewRequest { + t.Error("singleUse conn unexpectedly accepts a second request") + } + }) +} diff --git a/transport.go b/transport.go index 1fbf4e7a..cd65572f 100644 --- a/transport.go +++ b/transport.go @@ -19,6 +19,7 @@ import ( "fmt" "io" "log" + "math" "mime" "net" "net/http" @@ -445,6 +446,22 @@ func (t *Transport) SetHTTP2PriorityFrames(frames ...http2.PriorityFrame) *Trans return t } +// SetHTTP2NextStreamID sets the stream ID of the first client-initiated +// stream on new HTTP/2 connections (default 1). Some clients use a +// different starting value (e.g. OkHttp starts at 3), which is part of +// their HTTP/2 fingerprint. The value must be odd and fit into 31 bits +// (RFC 9113); invalid values are ignored. If priority frames are also +// configured (see SetHTTP2PriorityFrames), the counter advances past the +// stream IDs they claim, so their stream IDs should be greater than or +// equal to this value and given in increasing order. +func (t *Transport) SetHTTP2NextStreamID(id uint32) *Transport { + if id%2 == 0 || id > math.MaxInt32 { + return t + } + t.t2.NextStreamID = id + return t +} + // SetTLSClientConfig set the custom TLSClientConfig, which specifies the TLS configuration to // use with tls.Client. // If nil, the default configuration is used. @@ -778,6 +795,7 @@ func (t *Transport) Clone() *Transport { Settings: cloneSlice(t.t2.Settings), HeaderPriority: t.t2.HeaderPriority, PriorityFrames: cloneSlice(t.t2.PriorityFrames), + NextStreamID: t.t2.NextStreamID, } } if t.t3 != nil { diff --git a/transport_nextstreamid_test.go b/transport_nextstreamid_test.go new file mode 100644 index 00000000..2c042a0c --- /dev/null +++ b/transport_nextstreamid_test.go @@ -0,0 +1,34 @@ +package req + +import ( + "math" + "testing" +) + +func TestSetHTTP2NextStreamID(t *testing.T) { + c := C() + + // odd value passes through to the internal h2 transport + c.SetHTTP2NextStreamID(3) + if got := c.Transport.t2.NextStreamID; got != 3 { + t.Errorf("t2.NextStreamID = %d, want 3", got) + } + + // even values are ignored (client stream IDs must be odd) + c.SetHTTP2NextStreamID(4) + if got := c.Transport.t2.NextStreamID; got != 3 { + t.Errorf("t2.NextStreamID = %d, want 3 (even value ignored)", got) + } + + // values beyond 31 bits are ignored (RFC 9113) + c.SetHTTP2NextStreamID(math.MaxInt32 + 2) + if got := c.Transport.t2.NextStreamID; got != 3 { + t.Errorf("t2.NextStreamID = %d, want 3 (>31-bit value ignored)", got) + } + + // Transport.Clone carries the setting over + clone := c.Transport.Clone() + if got := clone.t2.NextStreamID; got != 3 { + t.Errorf("cloned t2.NextStreamID = %d, want 3", got) + } +}