Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions client_wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
47 changes: 45 additions & 2 deletions internal/http2/transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions internal/http2/transport_nextstreamid_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
18 changes: 18 additions & 0 deletions transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"fmt"
"io"
"log"
"math"
"mime"
"net"
"net/http"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions transport_nextstreamid_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading