Repository: quic-go/quic-go
Pull request #4391 (2024-03-27 02:19:29)

testutils: add a token parameter to ComposeInitialPacket

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4391?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 84.87%. Comparing base [(`d540f54`)](https://app.codecov.io/gh/quic-go/quic-go/commit/d540f545b0b70217220eb0fbd5278ece436a7a20?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`8358ab9`)](https://app.codecov.io/gh/quic-go/quic-go/pull/4391?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #4391      +/-   ##
==========================================
+ Coverage   84.85%   84.87%   +0.02%     
==========================================
  Files         152      152              
  Lines       14334    14334              
==========================================
+ Hits        12162    12165       +3     
+ Misses       1672     1670       -2     
+ Partials      500      499       -1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/4391?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).


Patch:
diff --git a/client.go b/client.go
index 70dd5e19..20ef7407 100644
--- a/client.go
+++ b/client.go
@@ -35,7 +35,7 @@ type client struct {
 	conn quicConn
 
 	tracer    *logging.ConnectionTracer
-	tracingID uint64
+	tracingID ConnectionTracingID
 	logger    utils.Logger
 }
 
diff --git a/connection.go b/connection.go
index f8bcd613..9c8821d9 100644
--- a/connection.go
+++ b/connection.go
@@ -113,8 +113,8 @@ func (e *errCloseForRecreating) Error() string {
 	return "closing connection in order to recreate it"
 }
 
-var connTracingID uint64        // to be accessed atomically
-func nextConnTracingID() uint64 { return atomic.AddUint64(&connTracingID, 1) }
+var connTracingID atomic.Uint64              // to be accessed atomically
+func nextConnTracingID() ConnectionTracingID { return ConnectionTracingID(connTracingID.Add(1)) }
 
 // A Connection is a QUIC connection
 type connection struct {
@@ -234,7 +234,7 @@ var newConnection = func(
 	tokenGenerator *handshake.TokenGenerator,
 	clientAddressValidated bool,
 	tracer *logging.ConnectionTracer,
-	tracingID uint64,
+	tracingID ConnectionTracingID,
 	logger utils.Logger,
 	v protocol.Version,
 ) quicConn {
@@ -272,8 +272,8 @@ var newConnection = func(
 		s.queueControlFrame,
 		connIDGenerator,
 	)
-	s.preSetup()
 	s.ctx, s.ctxCancel = context.WithCancelCause(context.WithValue(context.Background(), ConnectionTracingKey, tracingID))
+	s.preSetup()
 	s.sentPacketHandler, s.receivedPacketHandler = ackhandler.NewAckHandler(
 		0,
 		getMaxPacketSize(s.conn.RemoteAddr()),
@@ -347,7 +347,7 @@ var newClientConnection = func(
 	enable0RTT bool,
 	hasNegotiatedVersion bool,
 	tracer *logging.ConnectionTracer,
-	tracingID uint64,
+	tracingID ConnectionTracingID,
 	logger utils.Logger,
 	v protocol.Version,
 ) quicConn {
@@ -381,8 +381,8 @@ var newClientConnection = func(
 		s.queueControlFrame,
 		connIDGenerator,
 	)
-	s.preSetup()
 	s.ctx, s.ctxCancel = context.WithCancelCause(context.WithValue(context.Background(), ConnectionTracingKey, tracingID))
+	s.preSetup()
 	s.sentPacketHandler, s.receivedPacketHandler = ackhandler.NewAckHandler(
 		initialPacketNumber,
 		getMaxPacketSize(s.conn.RemoteAddr()),
@@ -471,6 +471,7 @@ func (s *connection) preSetup() {
 	)
 	s.earlyConnReadyChan = make(chan struct{})
 	s.streamsMap = newStreamsMap(
+		s.ctx,
 		s,
 		s.newFlowController,
 		uint64(s.config.MaxIncomingStreams),
diff --git a/http3/body.go b/http3/body.go
index dc168e9e..f496a959 100644
--- a/http3/body.go
+++ b/http3/body.go
@@ -2,8 +2,8 @@ package http3
 
 import (
 	"context"
+	"errors"
 	"io"
-	"net"
 
 	"github.com/quic-go/quic-go"
 )
@@ -17,40 +17,32 @@ type HTTPStreamer interface {
 	HTTPStream() Stream
 }
 
-type StreamCreator interface {
-	// Context returns a context that is cancelled when the underlying connection is closed.
-	Context() context.Context
-	OpenStream() (quic.Stream, error)
-	OpenStreamSync(context.Context) (quic.Stream, error)
-	OpenUniStream() (quic.SendStream, error)
-	OpenUniStreamSync(context.Context) (quic.SendStream, error)
-	LocalAddr() net.Addr
-	RemoteAddr() net.Addr
-	ConnectionState() quic.ConnectionState
-}
-
-var _ StreamCreator = quic.Connection(nil)
-
 // A Hijacker allows hijacking of the stream creating part of a quic.Session from a http.Response.Body.
 // It is used by WebTransport to create WebTransport streams after a session has been established.
 type Hijacker interface {
-	StreamCreator() StreamCreator
+	Connection() Connection
 }
 
-// The body of a http.Request or http.Response.
+var errTooMuchData = errors.New("peer sent too much data")
+
+// The body is used in the requestBody (for a http.Request) and the responseBody (for a http.Response).
 type body struct {
-	str quic.Stream
+	str *stream
+
+	remainingContentLength int64
+	violatedContentLength  bool
+	hasContentLength       bool
 
 	wasHijacked bool // set when HTTPStream is called
 }
 
-var (
-	_ io.ReadCloser = &body{}
-	_ HTTPStreamer  = &body{}
-)
-
-func newRequestBody(str Stream) *body {
-	return &body{str: str}
+func newBody(str *stream, contentLength int64) *body {
+	b := &body{str: str}
+	if contentLength >= 0 {
+		b.hasContentLength = true
+		b.remainingContentLength = contentLength
+	}
+	return b
 }
 
 func (r *body) HTTPStream() Stream {
@@ -58,12 +50,38 @@ func (r *body) HTTPStream() Stream {
 	return r.str
 }
 
+func (r *body) StreamID() quic.StreamID { return r.str.StreamID() }
 func (r *body) wasStreamHijacked() bool {
 	return r.wasHijacked
 }
 
+func (r *body) checkContentLengthViolation() error {
+	if !r.hasContentLength {
+		return nil
+	}
+	if r.remainingContentLength < 0 || r.remainingContentLength == 0 && r.str.hasMoreData() {
+		if !r.violatedContentLength {
+			r.str.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
+			r.str.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
+			r.violatedContentLength = true
+		}
+		return errTooMuchData
+	}
+	return nil
+}
+
 func (r *body) Read(b []byte) (int, error) {
+	if err := r.checkContentLengthViolation(); err != nil {
+		return 0, err
+	}
+	if r.hasContentLength {
+		b = b[:min(int64(len(b)), r.remainingContentLength)]
+	}
 	n, err := r.str.Read(b)
+	r.remainingContentLength -= int64(n)
+	if err := r.checkContentLengthViolation(); err != nil {
+		return n, err
+	}
 	return n, maybeReplaceError(err)
 }
 
@@ -72,9 +90,29 @@ func (r *body) Close() error {
 	return nil
 }
 
-type hijackableBody struct {
+type requestBody struct {
 	body
-	conn quic.Connection // only needed to implement Hijacker
+	connCtx      context.Context
+	rcvdSettings <-chan struct{}
+	getSettings  func() *Settings
+}
+
+var (
+	_ io.ReadCloser = &requestBody{}
+	_ HTTPStreamer  = &requestBody{}
+)
+
+func newRequestBody(str *stream, contentLength int64, connCtx context.Context, rcvdSettings <-chan struct{}, getSettings func() *Settings) *requestBody {
+	return &requestBody{
+		body:         *newBody(str, contentLength),
+		connCtx:      connCtx,
+		rcvdSettings: rcvdSettings,
+		getSettings:  getSettings,
+	}
+}
+
+type hijackableBody struct {
+	body body
 
 	// only set for the http.Response
 	// The channel is closed when the user is done with this response:
@@ -83,27 +121,17 @@ type hijackableBody struct {
 	reqDoneClosed bool
 }
 
-var (
-	_ Hijacker     = &hijackableBody{}
-	_ HTTPStreamer = &hijackableBody{}
-)
+var _ io.ReadCloser = &hijackableBody{}
 
-func newResponseBody(str Stream, conn quic.Connection, done chan<- struct{}) *hijackableBody {
+func newResponseBody(str *stream, contentLength int64, done chan<- struct{}) *hijackableBody {
 	return &hijackableBody{
-		body: body{
-			str: str,
-		},
+		body:    *newBody(str, contentLength),
 		reqDone: done,
-		conn:    conn,
 	}
 }
 
-func (r *hijackableBody) StreamCreator() StreamCreator {
-	return r.conn
-}
-
 func (r *hijackableBody) Read(b []byte) (int, error) {
-	n, err := r.str.Read(b)
+	n, err := r.body.Read(b)
 	if err != nil {
 		r.requestDone()
 	}
@@ -120,17 +148,9 @@ func (r *hijackableBody) requestDone() {
 	r.reqDoneClosed = true
 }
 
-func (r *body) StreamID() quic.StreamID {
-	return r.str.StreamID()
-}
-
 func (r *hijackableBody) Close() error {
 	r.requestDone()
 	// If the EOF was read, CancelRead() is a no-op.
-	r.str.CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled))
+	r.body.str.CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled))
 	return nil
 }
-
-func (r *hijackableBody) HTTPStream() Stream {
-	return r.str
-}
diff --git a/http3/client.go b/http3/client.go
index 1008eeea..61ded624 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -2,20 +2,18 @@ package http3
 
 import (
 	"context"
-	"crypto/tls"
 	"errors"
 	"fmt"
 	"io"
-	"net"
+	"log/slog"
 	"net/http"
-	"strconv"
+	"net/http/httptrace"
+	"net/textproto"
 	"sync"
-	"sync/atomic"
 	"time"
 
 	"github.com/quic-go/quic-go"
 	"github.com/quic-go/quic-go/internal/protocol"
-	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 
 	"github.com/quic-go/qpack"
@@ -40,127 +38,67 @@ var defaultQuicConfig = &quic.Config{
 	KeepAlivePeriod:    10 * time.Second,
 }
 
-type dialFunc func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error)
+// SingleDestinationRoundTripper is an HTTP/3 client doing requests to a single remote server.
+type SingleDestinationRoundTripper struct {
+	Connection quic.Connection
 
-var dialAddr dialFunc = quic.DialAddrEarly
+	// Enable support for HTTP/3 datagrams (RFC 9297).
+	// If a QUICConfig is set, datagram support also needs to be enabled on the QUIC layer by setting EnableDatagrams.
+	EnableDatagrams bool
 
-type roundTripperOpts struct {
-	DisableCompression bool
-	EnableDatagram     bool
-	MaxHeaderBytes     int64
+	// Additional HTTP/3 settings.
+	// It is invalid to specify any settings defined by RFC 9114 (HTTP/3) and RFC 9297 (HTTP Datagrams).
 	AdditionalSettings map[uint64]uint64
-	StreamHijacker     func(FrameType, quic.Connection, quic.Stream, error) (hijacked bool, err error)
-	UniStreamHijacker  func(StreamType, quic.Connection, quic.ReceiveStream, error) (hijacked bool)
-}
-
-// client is a HTTP3 client doing requests
-type client struct {
-	tlsConf *tls.Config
-	config  *quic.Config
-	opts    *roundTripperOpts
-
-	dialOnce     sync.Once
-	dialer       dialFunc
-	handshakeErr error
+	StreamHijacker     func(FrameType, quic.ConnectionTracingID, quic.Stream, error) (hijacked bool, err error)
+	UniStreamHijacker  func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool)
+
+	// MaxResponseHeaderBytes specifies a limit on how many response bytes are
+	// allowed in the server's response header.
+	// Zero means to use a default limit.
+	MaxResponseHeaderBytes int64
+
+	// DisableCompression, if true, prevents the Transport from requesting compression with an
+	// "Accept-Encoding: gzip" request header when the Request contains no existing Accept-Encoding value.
+	// If the Transport requests gzip on its own and gets a gzipped response, it's transparently
+	// decoded in the Response.Body.
+	// However, if the user explicitly requested gzip it is not automatically uncompressed.
+	DisableCompression bool
 
-	hconn *connection
+	Logger *slog.Logger
 
+	initOnce      sync.Once
+	hconn         *connection
 	requestWriter *requestWriter
-
-	decoder *qpack.Decoder
-
-	hostname string
-	conn     atomic.Pointer[quic.EarlyConnection]
-
-	logger utils.Logger
+	decoder       *qpack.Decoder
 }
 
-var _ roundTripCloser = &client{}
+var _ http.RoundTripper = &SingleDestinationRoundTripper{}
 
-func newClient(hostname string, tlsConf *tls.Config, opts *roundTripperOpts, conf *quic.Config, dialer dialFunc) (roundTripCloser, error) {
-	if conf == nil {
-		conf = defaultQuicConfig.Clone()
-		conf.EnableDatagrams = opts.EnableDatagram
-	}
-	if opts.EnableDatagram && !conf.EnableDatagrams {
-		return nil, errors.New("HTTP Datagrams enabled, but QUIC Datagrams disabled")
-	}
-	if len(conf.Versions) == 0 {
-		conf = conf.Clone()
-		conf.Versions = []quic.Version{protocol.SupportedVersions[0]}
-	}
-	if len(conf.Versions) != 1 {
-		return nil, errors.New("can only use a single QUIC version for dialing a HTTP/3 connection")
-	}
-	if conf.MaxIncomingStreams == 0 {
-		conf.MaxIncomingStreams = -1 // don't allow any bidirectional streams
-	}
-	logger := utils.DefaultLogger.WithPrefix("h3 client")
-
-	if tlsConf == nil {
-		tlsConf = &tls.Config{}
-	} else {
-		tlsConf = tlsConf.Clone()
-	}
-	if tlsConf.ServerName == "" {
-		sni, _, err := net.SplitHostPort(hostname)
-		if err != nil {
-			// It's ok if net.SplitHostPort returns an error - it could be a hostname/IP address without a port.
-			sni = hostname
-		}
-		tlsConf.ServerName = sni
-	}
-	// Replace existing ALPNs by H3
-	tlsConf.NextProtos = []string{versionToALPN(conf.Versions[0])}
-
-	return &client{
-		hostname:      authorityAddr("https", hostname),
-		tlsConf:       tlsConf,
-		requestWriter: newRequestWriter(logger),
-		decoder:       qpack.NewDecoder(func(hf qpack.HeaderField) {}),
-		config:        conf,
-		opts:          opts,
-		dialer:        dialer,
-		logger:        logger,
-	}, nil
+func (c *SingleDestinationRoundTripper) Start() Connection {
+	c.initOnce.Do(func() { c.init() })
+	return c.hconn
 }
 
-func (c *client) dial(ctx context.Context) error {
-	var err error
-	var conn quic.EarlyConnection
-	if c.dialer != nil {
-		conn, err = c.dialer(ctx, c.hostname, c.tlsConf, c.config)
-	} else {
-		conn, err = dialAddr(ctx, c.hostname, c.tlsConf, c.config)
-	}
-	if err != nil {
-		return err
-	}
-	c.conn.Store(&conn)
-
+func (c *SingleDestinationRoundTripper) init() {
+	c.requestWriter = newRequestWriter()
+	c.decoder = qpack.NewDecoder(func(hf qpack.HeaderField) {})
+	c.hconn = newConnection(c.Connection, c.EnableDatagrams, c.UniStreamHijacker, protocol.PerspectiveClient, c.Logger)
 	// send the SETTINGs frame, using 0-RTT data, if possible
 	go func() {
-		if err := c.setupConn(conn); err != nil {
-			c.logger.Debugf("Setting up connection failed: %s", err)
-			conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeInternalError), "")
+		if err := c.setupConn(c.Connection); err != nil {
+			if c.Logger != nil {
+				c.Logger.Debug("setting up connection failed", "error", err)
+			}
+			c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeInternalError), "")
 		}
 	}()
-
-	if c.opts.StreamHijacker != nil {
-		go c.handleBidirectionalStreams(conn)
+	if c.StreamHijacker != nil {
+		go c.handleBidirectionalStreams()
 	}
-	c.hconn = newConnection(
-		conn,
-		c.opts.EnableDatagram,
-		c.opts.UniStreamHijacker,
-		protocol.PerspectiveClient,
-		c.logger,
-	)
 	go c.hconn.HandleUnidirectionalStreams()
-	return nil
 }
 
-func (c *client) setupConn(conn quic.EarlyConnection) error {
+func (c *SingleDestinationRoundTripper) setupConn(conn quic.Connection) error {
 	// open the control stream
 	str, err := conn.OpenUniStream()
 	if err != nil {
@@ -169,51 +107,50 @@ func (c *client) setupConn(conn quic.EarlyConnection) error {
 	b := make([]byte, 0, 64)
 	b = quicvarint.Append(b, streamTypeControlStream)
 	// send the SETTINGS frame
-	b = (&settingsFrame{Datagram: c.opts.EnableDatagram, Other: c.opts.AdditionalSettings}).Append(b)
+	b = (&settingsFrame{Datagram: c.EnableDatagrams, Other: c.AdditionalSettings}).Append(b)
 	_, err = str.Write(b)
 	return err
 }
 
-func (c *client) handleBidirectionalStreams(conn quic.EarlyConnection) {
+func (c *SingleDestinationRoundTripper) handleBidirectionalStreams() {
 	for {
-		str, err := conn.AcceptStream(context.Background())
+		str, err := c.hconn.AcceptStream(context.Background())
 		if err != nil {
-			c.logger.Debugf("accepting bidirectional stream failed: %s", err)
+			if c.Logger != nil {
+				c.Logger.Debug("accepting bidirectional stream failed", "error", err)
+			}
 			return
 		}
 		go func(str quic.Stream) {
 			_, err := parseNextFrame(str, func(ft FrameType, e error) (processed bool, err error) {
-				return c.opts.StreamHijacker(ft, conn, str, e)
+				id := c.hconn.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID)
+				return c.StreamHijacker(ft, id, str, e)
 			})
 			if err == errHijacked {
 				return
 			}
 			if err != nil {
-				c.logger.Debugf("error handling stream: %s", err)
+				if c.Logger != nil {
+					c.Logger.Debug("error handling stream", "error", err)
+				}
 			}
-			conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "received HTTP/3 frame on bidirectional stream")
+			c.hconn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "received HTTP/3 frame on bidirectional stream")
 		}(str)
 	}
 }
 
-func (c *client) Close() error {
-	conn := c.conn.Load()
-	if conn == nil {
-		return nil
-	}
-	return (*conn).CloseWithError(quic.ApplicationErrorCode(ErrCodeNoError), "")
-}
-
-func (c *client) maxHeaderBytes() uint64 {
-	if c.opts.MaxHeaderBytes <= 0 {
+func (c *SingleDestinationRoundTripper) maxHeaderBytes() uint64 {
+	if c.MaxResponseHeaderBytes <= 0 {
 		return defaultMaxResponseHeaderBytes
 	}
-	return uint64(c.opts.MaxHeaderBytes)
+	return uint64(c.MaxResponseHeaderBytes)
 }
 
-// RoundTripOpt executes a request and returns a response
-func (c *client) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
-	rsp, err := c.roundTripOpt(req, opt)
+// RoundTrip executes a request and returns a response
+func (c *SingleDestinationRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+	c.initOnce.Do(func() { c.init() })
+
+	rsp, err := c.roundTrip(req)
 	if err != nil && req.Context().Err() != nil {
 		// if the context was canceled, return the context cancellation error
 		err = req.Context().Err()
@@ -221,21 +158,7 @@ func (c *client) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Respon
 	return rsp, err
 }
 
-func (c *client) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
-	if authorityAddr("https", hostnameFromRequest(req)) != c.hostname {
-		return nil, fmt.Errorf("http3 client BUG: RoundTripOpt called for the wrong client (expected %s, got %s)", c.hostname, req.Host)
-	}
-
-	c.dialOnce.Do(func() {
-		c.handshakeErr = c.dial(req.Context())
-	})
-	if c.handshakeErr != nil {
-		return nil, c.handshakeErr
-	}
-
-	// At this point, c.conn is guaranteed to be set.
-	conn := *c.conn.Load()
-
+func (c *SingleDestinationRoundTripper) roundTrip(req *http.Request) (*http.Response, error) {
 	// Immediately send out this request, if this is a 0-RTT request.
 	switch req.Method {
 	case MethodGet0RTT:
@@ -250,26 +173,32 @@ func (c *client) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Respon
 		req.Method = http.MethodHead
 	default:
 		// wait for the handshake to complete
-		select {
-		case <-conn.HandshakeComplete():
-		case <-req.Context().Done():
-			return nil, req.Context().Err()
+		earlyConn, ok := c.Connection.(quic.EarlyConnection)
+		if ok {
+			select {
+			case <-earlyConn.HandshakeComplete():
+			case <-req.Context().Done():
+				return nil, req.Context().Err()
+			}
 		}
 	}
 
-	if opt.CheckSettings != nil {
+	// It is only possible to send an Extended CONNECT request once the SETTINGS were received.
+	// See section 3 of RFC 8441.
+	if isExtendedConnectRequest(req) {
+		connCtx := c.Connection.Context()
 		// wait for the server's SETTINGS frame to arrive
 		select {
 		case <-c.hconn.ReceivedSettings():
-		case <-conn.Context().Done():
-			return nil, context.Cause(conn.Context())
+		case <-connCtx.Done():
+			return nil, context.Cause(connCtx)
 		}
-		if err := opt.CheckSettings(*c.hconn.Settings()); err != nil {
-			return nil, err
+		if !c.hconn.Settings().EnableExtendedConnect {
+			return nil, errors.New("http3: server didn't enable Extended CONNECT")
 		}
 	}
 
-	str, err := conn.OpenStreamSync(req.Context())
+	str, err := c.Connection.OpenStreamSync(req.Context())
 	if err != nil {
 		return nil, err
 	}
@@ -289,31 +218,30 @@ func (c *client) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Respon
 		}
 	}()
 
-	doneChan := reqDone
-	if opt.DontCloseRequestStream {
-		doneChan = nil
-	}
-	rsp, rerr := c.doRequest(req, conn, str, opt, doneChan)
-	if rerr.err != nil { // if any error occurred
+	rsp, err := c.doRequest(req, str, reqDone)
+	if err != nil { // if any error occurred
 		close(reqDone)
 		<-done
-		if rerr.streamErr != 0 { // if it was a stream error
-			str.CancelWrite(quic.StreamErrorCode(rerr.streamErr))
-		}
-		if rerr.connErr != 0 { // if it was a connection error
-			var reason string
-			if rerr.err != nil {
-				reason = rerr.err.Error()
-			}
-			conn.CloseWithError(quic.ApplicationErrorCode(rerr.connErr), reason)
-		}
-		return nil, maybeReplaceError(rerr.err)
+		return nil, maybeReplaceError(err)
 	}
-	if opt.DontCloseRequestStream {
-		close(reqDone)
-		<-done
+	return rsp, maybeReplaceError(err)
+}
+
+func (c *SingleDestinationRoundTripper) OpenRequestStream(ctx context.Context) (RequestStream, error) {
+	c.initOnce.Do(func() { c.init() })
+
+	str, err := c.Connection.OpenStreamSync(ctx)
+	if err != nil {
+		return nil, err
 	}
-	return rsp, maybeReplaceError(rerr.err)
+	return newRequestStream(
+		newStream(str, c.hconn),
+		c.requestWriter,
+		nil,
+		c.decoder,
+		c.DisableCompression,
+		c.maxHeaderBytes(),
+	), nil
 }
 
 // cancelingReader reads from the io.Reader.
@@ -331,7 +259,7 @@ func (r *cancelingReader) Read(b []byte) (int, error) {
 	return n, err
 }
 
-func (c *client) sendRequestBody(str Stream, body io.ReadCloser, contentLength int64) error {
+func (c *SingleDestinationRoundTripper) sendRequestBody(str Stream, body io.ReadCloser, contentLength int64) error {
 	defer body.Close()
 	buf := make([]byte, bodyCopyBufferSize)
 	sr := &cancelingReader{str: str, r: body}
@@ -355,21 +283,21 @@ func (c *client) sendRequestBody(str Stream, body io.ReadCloser, contentLength i
 	return err
 }
 
-func (c *client) doRequest(req *http.Request, conn quic.EarlyConnection, str quic.Stream, opt RoundTripOpt, reqDone chan<- struct{}) (*http.Response, requestError) {
-	var requestGzip bool
-	if !c.opts.DisableCompression && req.Method != "HEAD" && req.Header.Get("Accept-Encoding") == "" && req.Header.Get("Range") == "" {
-		requestGzip = true
-	}
-	if err := c.requestWriter.WriteRequestHeader(str, req, requestGzip); err != nil {
-		return nil, newStreamError(ErrCodeInternalError, err)
-	}
-
-	if req.Body == nil && !opt.DontCloseRequestStream {
-		str.Close()
+func (c *SingleDestinationRoundTripper) doRequest(req *http.Request, str quic.Stream, reqDone chan<- struct{}) (*http.Response, error) {
+	hstr := newRequestStream(
+		newStream(str, c.hconn),
+		c.requestWriter,
+		reqDone,
+		c.decoder,
+		c.DisableCompression,
+		c.maxHeaderBytes(),
+	)
+	if err := hstr.SendRequestHeader(req); err != nil {
+		return nil, err
 	}
-
-	hstr := newStream(str, func() { conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "") })
-	if req.Body != nil {
+	if req.Body == nil {
+		hstr.Close()
+	} else {
 		// send the request body asynchronously
 		go func() {
 			contentLength := int64(-1)
@@ -379,88 +307,48 @@ func (c *client) doRequest(req *http.Request, conn quic.EarlyConnection, str qui
 				contentLength = req.ContentLength
 			}
 			if err := c.sendRequestBody(hstr, req.Body, contentLength); err != nil {
-				c.logger.Errorf("Error writing request: %s", err)
-			}
-			if !opt.DontCloseRequestStream {
-				hstr.Close()
+				if c.Logger != nil {
+					c.Logger.Debug("error writing request", "error", err)
+				}
 			}
+			hstr.Close()
 		}()
 	}
 
-	frame, err := parseNextFrame(str, nil)
-	if err != nil {
-		return nil, newStreamError(ErrCodeFrameError, err)
-	}
-	hf, ok := frame.(*headersFrame)
-	if !ok {
-		return nil, newConnError(ErrCodeFrameUnexpected, errors.New("expected first frame to be a HEADERS frame"))
-	}
-	if hf.Length > c.maxHeaderBytes() {
-		return nil, newStreamError(ErrCodeFrameError, fmt.Errorf("HEADERS frame too large: %d bytes (max: %d)", hf.Length, c.maxHeaderBytes()))
-	}
-	headerBlock := make([]byte, hf.Length)
-	if _, err := io.ReadFull(str, headerBlock); err != nil {
-		return nil, newStreamError(ErrCodeRequestIncomplete, err)
-	}
-	hfs, err := c.decoder.DecodeFull(headerBlock)
-	if err != nil {
-		// TODO: use the right error code
-		return nil, newConnError(ErrCodeGeneralProtocolError, err)
-	}
+	var (
+		res *http.Response
+		err error
+	)
 
-	res, err := responseFromHeaders(hfs)
-	if err != nil {
-		return nil, newStreamError(ErrCodeMessageError, err)
-	}
-	connState := conn.ConnectionState().TLS
-	res.TLS = &connState
-	res.Request = req
-	// Check that the server doesn't send more data in DATA frames than indicated by the Content-Length header (if set).
-	// See section 4.1.2 of RFC 9114.
-	var httpStr Stream
-	if _, ok := res.Header["Content-Length"]; ok && res.ContentLength >= 0 {
-		httpStr = newLengthLimitedStream(hstr, res.ContentLength)
-	} else {
-		httpStr = hstr
-	}
-	respBody := newResponseBody(httpStr, conn, reqDone)
-
-	// Rules for when to set Content-Length are defined in https://tools.ietf.org/html/rfc7230#section-3.3.2.
-	_, hasTransferEncoding := res.Header["Transfer-Encoding"]
-	isInformational := res.StatusCode >= 100 && res.StatusCode < 200
-	isNoContent := res.StatusCode == http.StatusNoContent
-	isSuccessfulConnect := req.Method == http.MethodConnect && res.StatusCode >= 200 && res.StatusCode < 300
-	if !hasTransferEncoding && !isInformational && !isNoContent && !isSuccessfulConnect {
-		res.ContentLength = -1
-		if clens, ok := res.Header["Content-Length"]; ok && len(clens) == 1 {
-			if clen64, err := strconv.ParseInt(clens[0], 10, 64); err == nil {
-				res.ContentLength = clen64
+	// copy from net/http: support 1xx responses
+	trace := httptrace.ContextClientTrace(req.Context())
+	num1xx := 0               // number of informational 1xx headers received
+	const max1xxResponses = 5 // arbitrary bound on number of informational responses
+
+	for {
+		if res, err = hstr.ReadResponse(); err != nil {
+			return nil, err
+		}
+		resCode := res.StatusCode
+		is1xx := 100 <= resCode && resCode <= 199
+		// treat 101 as a terminal status, see https://github.com/golang/go/issues/26161
+		is1xxNonTerminal := is1xx && resCode != http.StatusSwitchingProtocols
+		if is1xxNonTerminal {
+			num1xx++
+			if num1xx > max1xxResponses {
+				return nil, errors.New("http: too many 1xx informational responses")
+			}
+			if trace != nil && trace.Got1xxResponse != nil {
+				if err := trace.Got1xxResponse(resCode, textproto.MIMEHeader(res.Header)); err != nil {
+					return nil, err
+				}
 			}
+			continue
 		}
+		break
 	}
-
-	if requestGzip && res.Header.Get("Content-Encoding") == "gzip" {
-		res.Header.Del("Content-Encoding")
-		res.Header.Del("Content-Length")
-		res.ContentLength = -1
-		res.Body = newGzipReader(respBody)
-		res.Uncompressed = true
-	} else {
-		res.Body = respBody
-	}
-
-	return res, requestError{}
-}
-
-func (c *client) HandshakeComplete() bool {
-	conn := c.conn.Load()
-	if conn == nil {
-		return false
-	}
-	select {
-	case <-(*conn).HandshakeComplete():
-		return true
-	default:
-		return false
-	}
+	connState := c.Connection.ConnectionState().TLS
+	res.TLS = &connState
+	res.Request = req
+	return res, nil
 }
diff --git a/http3/conn.go b/http3/conn.go
index a2da426d..1898d92a 100644
--- a/http3/conn.go
+++ b/http3/conn.go
@@ -2,21 +2,43 @@ package http3
 
 import (
 	"context"
+	"log/slog"
+	"net"
 	"sync/atomic"
 
 	"github.com/quic-go/quic-go"
 	"github.com/quic-go/quic-go/internal/protocol"
-	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 )
 
+// Connection is an HTTP/3 connection.
+// It has all methods from the quic.Connection expect for AcceptStream, AcceptUniStream,
+// SendDatagram and ReceiveDatagram.
+type Connection interface {
+	OpenStream() (quic.Stream, error)
+	OpenStreamSync(context.Context) (quic.Stream, error)
+	OpenUniStream() (quic.SendStream, error)
+	OpenUniStreamSync(context.Context) (quic.SendStream, error)
+	LocalAddr() net.Addr
+	RemoteAddr() net.Addr
+	CloseWithError(quic.ApplicationErrorCode, string) error
+	Context() context.Context
+	ConnectionState() quic.ConnectionState
+
+	// ReceivedSettings returns a channel that is closed once the client's SETTINGS frame was received.
+	ReceivedSettings() <-chan struct{}
+	// Settings returns the settings received on this connection.
+	Settings() *Settings
+}
+
 type connection struct {
-	quicConn    quic.Connection
+	quic.Connection
+
 	perspective protocol.Perspective
-	logger      utils.Logger
+	logger      *slog.Logger
 
 	enableDatagrams   bool
-	uniStreamHijacker func(StreamType, quic.Connection, quic.ReceiveStream, error) (hijacked bool)
+	uniStreamHijacker func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool)
 
 	settings         *Settings
 	receivedSettings chan struct{}
@@ -25,12 +47,12 @@ type connection struct {
 func newConnection(
 	quicConn quic.Connection,
 	enableDatagrams bool,
-	uniStreamHijacker func(StreamType, quic.Connection, quic.ReceiveStream, error) (hijacked bool),
+	uniStreamHijacker func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool),
 	perspective protocol.Perspective,
-	logger utils.Logger,
+	logger *slog.Logger,
 ) *connection {
 	return &connection{
-		quicConn:          quicConn,
+		Connection:        quicConn,
 		perspective:       perspective,
 		logger:            logger,
 		enableDatagrams:   enableDatagrams,
@@ -47,19 +69,24 @@ func (c *connection) HandleUnidirectionalStreams() {
 	)
 
 	for {
-		str, err := c.quicConn.AcceptUniStream(context.Background())
+		str, err := c.Connection.AcceptUniStream(context.Background())
 		if err != nil {
-			c.logger.Debugf("accepting unidirectional stream failed: %s", err)
+			if c.logger != nil {
+				c.logger.Debug("accepting unidirectional stream failed", "error", err)
+			}
 			return
 		}
 
 		go func(str quic.ReceiveStream) {
 			streamType, err := quicvarint.Read(quicvarint.NewReader(str))
 			if err != nil {
-				if c.uniStreamHijacker != nil && c.uniStreamHijacker(StreamType(streamType), c.quicConn, str, err) {
+				id := c.Connection.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID)
+				if c.uniStreamHijacker != nil && c.uniStreamHijacker(StreamType(streamType), id, str, err) {
 					return
 				}
-				c.logger.Debugf("reading stream type on stream %d failed: %s", str.StreamID(), err)
+				if c.logger != nil {
+					c.logger.Debug("reading stream type on stream failed", "stream ID", str.StreamID(), "error", err)
+				}
 				return
 			}
 			// We're only interested in the control stream here.
@@ -67,13 +94,13 @@ func (c *connection) HandleUnidirectionalStreams() {
 			case streamTypeControlStream:
 			case streamTypeQPACKEncoderStream:
 				if isFirst := rcvdQPACKEncoderStr.CompareAndSwap(false, true); !isFirst {
-					c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate QPACK encoder stream")
+					c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate QPACK encoder stream")
 				}
 				// Our QPACK implementation doesn't use the dynamic table yet.
 				return
 			case streamTypeQPACKDecoderStream:
 				if isFirst := rcvdQPACKDecoderStr.CompareAndSwap(false, true); !isFirst {
-					c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate QPACK decoder stream")
+					c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate QPACK decoder stream")
 				}
 				// Our QPACK implementation doesn't use the dynamic table yet.
 				return
@@ -81,32 +108,39 @@ func (c *connection) HandleUnidirectionalStreams() {
 				switch c.perspective {
 				case protocol.PerspectiveClient:
 					// we never increased the Push ID, so we don't expect any push streams
-					c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeIDError), "")
+					c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeIDError), "")
 				case protocol.PerspectiveServer:
 					// only the server can push
-					c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "")
+					c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "")
 				}
 				return
 			default:
-				if c.uniStreamHijacker != nil && c.uniStreamHijacker(StreamType(streamType), c.quicConn, str, nil) {
-					return
+				if c.uniStreamHijacker != nil {
+					if c.uniStreamHijacker(
+						StreamType(streamType),
+						c.Connection.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID),
+						str,
+						nil,
+					) {
+						return
+					}
 				}
 				str.CancelRead(quic.StreamErrorCode(ErrCodeStreamCreationError))
 				return
 			}
 			// Only a single control stream is allowed.
 			if isFirstControlStr := rcvdControlStr.CompareAndSwap(false, true); !isFirstControlStr {
-				c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate control stream")
+				c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeStreamCreationError), "duplicate control stream")
 				return
 			}
 			f, err := parseNextFrame(str, nil)
 			if err != nil {
-				c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameError), "")
+				c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameError), "")
 				return
 			}
 			sf, ok := f.(*settingsFrame)
 			if !ok {
-				c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeMissingSettings), "")
+				c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeMissingSettings), "")
 				return
 			}
 			c.settings = &Settings{
@@ -123,8 +157,8 @@ func (c *connection) HandleUnidirectionalStreams() {
 			// If datagram support was enabled on our side as well as on the server side,
 			// we can expect it to have been negotiated both on the transport and on the HTTP/3 layer.
 			// Note: ConnectionState() will block until the handshake is complete (relevant when using 0-RTT).
-			if c.enableDatagrams && !c.quicConn.ConnectionState().SupportsDatagrams {
-				c.quicConn.CloseWithError(quic.ApplicationErrorCode(ErrCodeSettingsError), "missing QUIC Datagram support")
+			if c.enableDatagrams && !c.Connection.ConnectionState().SupportsDatagrams {
+				c.Connection.CloseWithError(quic.ApplicationErrorCode(ErrCodeSettingsError), "missing QUIC Datagram support")
 			}
 		}(str)
 	}
diff --git a/http3/headers.go b/http3/headers.go
index 04066717..d587efd4 100644
--- a/http3/headers.go
+++ b/http3/headers.go
@@ -171,9 +171,9 @@ func requestFromHeaders(headerFields []qpack.HeaderField) (*http.Request, error)
 	}, nil
 }
 
-func hostnameFromRequest(req *http.Request) string {
-	if req.URL != nil {
-		return req.URL.Host
+func hostnameFromURL(url *url.URL) string {
+	if url != nil {
+		return url.Host
 	}
 	return ""
 }
diff --git a/http3/http_stream.go b/http3/http_stream.go
index bfaf4214..32792a4f 100644
--- a/http3/http_stream.go
+++ b/http3/http_stream.go
@@ -3,32 +3,53 @@ package http3
 import (
 	"errors"
 	"fmt"
+	"io"
+	"net/http"
+	"strconv"
 
 	"github.com/quic-go/quic-go"
+	"github.com/quic-go/quic-go/internal/protocol"
+
+	"github.com/quic-go/qpack"
 )
 
-// A Stream is a HTTP/3 stream.
+// A Stream is an HTTP/3 request stream.
 // When writing to and reading from the stream, data is framed in HTTP/3 DATA frames.
-type Stream quic.Stream
+type Stream = quic.Stream
+
+// A RequestStream is an HTTP/3 request stream.
+// When writing to and reading from the stream, data is framed in HTTP/3 DATA frames.
+type RequestStream interface {
+	quic.Stream
+
+	// SendRequestHeader sends the HTTP request.
+	// It is invalid to call it more than once.
+	// It is invalid to call it after Write has been called.
+	SendRequestHeader(req *http.Request) error
+
+	// ReadResponse reads the HTTP response from the stream.
+	// It is invalid to call it more than once.
+	// It doesn't set Response.Request and Response.TLS.
+	// It is invalid to call it after Read has been called.
+	ReadResponse() (*http.Response, error)
+}
 
-// The stream conforms to the quic.Stream interface, but instead of writing to and reading directly
-// from the QUIC stream, it writes to and reads from the HTTP stream.
 type stream struct {
 	quic.Stream
+	conn quic.Connection
 
-	buf []byte
+	buf []byte // used as a temporary buffer when writing the HTTP/3 frame headers
 
-	onFrameError          func()
 	bytesRemainingInFrame uint64
 }
 
 var _ Stream = &stream{}
 
-func newStream(str quic.Stream, onFrameError func()) *stream {
+func newStream(str quic.Stream, conn quic.Connection) *stream {
 	return &stream{
-		Stream:       str,
-		onFrameError: onFrameError,
-		buf:          make([]byte, 0, 16),
+		Stream: str,
+		conn:   conn,
+		buf:    make([]byte, 0, 16),
 	}
 }
 
@@ -48,7 +69,7 @@ func (s *stream) Read(b []byte) (int, error) {
 				s.bytesRemainingInFrame = f.Length
 				break parseLoop
 			default:
-				s.onFrameError()
+				s.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "")
 				// parseNextFrame skips over unknown frame types
 				// Therefore, this condition is only entered when we parsed another known frame type.
 				return 0, fmt.Errorf("peer sent an unexpected frame: %T", f)
@@ -80,44 +101,140 @@ func (s *stream) Write(b []byte) (int, error) {
 	return s.Stream.Write(b)
 }
 
-var errTooMuchData = errors.New("peer sent too much data")
+func (s *stream) writeUnframed(b []byte) (int, error) {
+	return s.Stream.Write(b)
+}
+
+func (s *stream) StreamID() protocol.StreamID {
+	return s.Stream.StreamID()
+}
 
-type lengthLimitedStream struct {
+// The stream conforms to the quic.Stream interface, but instead of writing to and reading directly
+// from the QUIC stream, it writes to and reads from the HTTP stream.
+type requestStream struct {
 	*stream
-	contentLength int64
-	read          int64
-	resetStream   bool
+
+	responseBody io.ReadCloser // set by ReadResponse
+
+	decoder            *qpack.Decoder
+	requestWriter      *requestWriter
+	maxHeaderBytes     uint64
+	reqDone            chan<- struct{}
+	disableCompression bool
+
+	sentRequest   bool
+	requestedGzip bool
+	isConnect     bool
 }
 
-var _ Stream = &lengthLimitedStream{}
+var _ RequestStream = &requestStream{}
+
+func newRequestStream(
+	str *stream,
+	requestWriter *requestWriter,
+	reqDone chan<- struct{},
+	decoder *qpack.Decoder,
+	disableCompression bool,
+	maxHeaderBytes uint64,
+) *requestStream {
+	return &requestStream{
+		stream:             str,
+		requestWriter:      requestWriter,
+		reqDone:            reqDone,
+		decoder:            decoder,
+		disableCompression: disableCompression,
+		maxHeaderBytes:     maxHeaderBytes,
+	}
+}
 
-func newLengthLimitedStream(str *stream, contentLength int64) *lengthLimitedStream {
-	return &lengthLimitedStream{
-		stream:        str,
-		contentLength: contentLength,
+func (s *requestStream) Read(b []byte) (int, error) {
+	if s.responseBody == nil {
+		return 0, errors.New("http3: invalid use of RequestStream.Read: need to call ReadResponse first")
 	}
+	return s.responseBody.Read(b)
 }
 
-func (s *lengthLimitedStream) checkContentLengthViolation() error {
-	if s.read > s.contentLength || s.read == s.contentLength && s.hasMoreData() {
-		if !s.resetStream {
-			s.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
-			s.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
-			s.resetStream = true
-		}
-		return errTooMuchData
+func (s *requestStream) SendRequestHeader(req *http.Request) error {
+	if s.sentRequest {
+		return errors.New("http3: invalid duplicate use of SendRequestHeader")
+	}
+	if !s.disableCompression && req.Method != http.MethodHead &&
+		req.Header.Get("Accept-Encoding") == "" && req.Header.Get("Range") == "" {
+		s.requestedGzip = true
 	}
-	return nil
+	s.isConnect = req.Method == http.MethodConnect
+	s.sentRequest = true
+	return s.requestWriter.WriteRequestHeader(s.Stream, req, s.requestedGzip)
 }
 
-func (s *lengthLimitedStream) Read(b []byte) (int, error) {
-	if err := s.checkContentLengthViolation(); err != nil {
-		return 0, err
+func (s *requestStream) ReadResponse() (*http.Response, error) {
+	frame, err := parseNextFrame(s.Stream, nil)
+	if err != nil {
+		s.Stream.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
+		s.Stream.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
+		return nil, fmt.Errorf("http3: parsing frame failed: %w", err)
 	}
-	n, err := s.stream.Read(b[:min(int64(len(b)), s.contentLength-s.read)])
-	s.read += int64(n)
-	if err := s.checkContentLengthViolation(); err != nil {
-		return n, err
+	hf, ok := frame.(*headersFrame)
+	if !ok {
+		s.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "expected first frame to be a HEADERS frame")
+		return nil, errors.New("http3: expected first frame to be a HEADERS frame")
 	}
-	return n, err
+	if hf.Length > s.maxHeaderBytes {
+		s.Stream.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
+		s.Stream.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
+		return nil, fmt.Errorf("http3: HEADERS frame too large: %d bytes (max: %d)", hf.Length, s.maxHeaderBytes)
+	}
+	headerBlock := make([]byte, hf.Length)
+	if _, err := io.ReadFull(s.Stream, headerBlock); err != nil {
+		s.Stream.CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+		s.Stream.CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+		return nil, fmt.Errorf("http3: failed to read response headers: %w", err)
+	}
+	hfs, err := s.decoder.DecodeFull(headerBlock)
+	if err != nil {
+		// TODO: use the right error code
+		s.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeGeneralProtocolError), "")
+		return nil, fmt.Errorf("http3: failed to decode response headers: %w", err)
+	}
+
+	res, err := responseFromHeaders(hfs)
+	if err != nil {
+		s.Stream.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
+		s.Stream.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
+		return nil, fmt.Errorf("http3: invalid response: %w", err)
+	}
+
+	// Check that the server doesn't send more data in DATA frames than indicated by the Content-Length header (if set).
+	// See section 4.1.2 of RFC 9114.
+	contentLength := int64(-1)
+	if _, ok := res.Header["Content-Length"]; ok && res.ContentLength >= 0 {
+		contentLength = res.ContentLength
+	}
+	respBody := newResponseBody(s.stream, contentLength, s.reqDone)
+
+	// Rules for when to set Content-Length are defined in https://tools.ietf.org/html/rfc7230#section-3.3.2.
+	_, hasTransferEncoding := res.Header["Transfer-Encoding"]
+	isInformational := res.StatusCode >= 100 && res.StatusCode < 200
+	isNoContent := res.StatusCode == http.StatusNoContent
+	isSuccessfulConnect := s.isConnect && res.StatusCode >= 200 && res.StatusCode < 300
+	if !hasTransferEncoding && !isInformational && !isNoContent && !isSuccessfulConnect {
+		res.ContentLength = -1
+		if clens, ok := res.Header["Content-Length"]; ok && len(clens) == 1 {
+			if clen64, err := strconv.ParseInt(clens[0], 10, 64); err == nil {
+				res.ContentLength = clen64
+			}
+		}
+	}
+
+	if s.requestedGzip && res.Header.Get("Content-Encoding") == "gzip" {
+		res.Header.Del("Content-Encoding")
+		res.Header.Del("Content-Length")
+		res.ContentLength = -1
+		s.responseBody = newGzipReader(respBody)
+		res.Uncompressed = true
+	} else {
+		s.responseBody = respBody
+	}
+	res.Body = s.responseBody
+	return res, nil
 }
diff --git a/http3/mockgen.go b/http3/mockgen.go
index 57af7972..83a3974f 100644
--- a/http3/mockgen.go
+++ b/http3/mockgen.go
@@ -2,7 +2,7 @@
 
 package http3
 
-//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\"  -package http3 -destination mock_roundtripcloser_test.go github.com/quic-go/quic-go/http3 RoundTripCloser"
-type RoundTripCloser = roundTripCloser
+//go:generate sh -c "go run go.uber.org/mock/mockgen -typed -build_flags=\"-tags=gomock\"  -package http3 -destination mock_singleroundtripper_test.go github.com/quic-go/quic-go/http3 SingleRoundTripper"
+type SingleRoundTripper = singleRoundTripper
 
 //go:generate sh -c "go run go.uber.org/mock/mockgen -typed -package http3 -destination mock_quic_early_listener_test.go github.com/quic-go/quic-go/http3 QUICEarlyListener"
diff --git a/http3/request_writer.go b/http3/request_writer.go
index 875f4031..e83e9cc8 100644
--- a/http3/request_writer.go
+++ b/http3/request_writer.go
@@ -17,7 +17,6 @@ import (
 
 	"github.com/quic-go/qpack"
 	"github.com/quic-go/quic-go"
-	"github.com/quic-go/quic-go/internal/utils"
 )
 
 const bodyCopyBufferSize = 8 * 1024
@@ -26,17 +25,14 @@ type requestWriter struct {
 	mutex     sync.Mutex
 	encoder   *qpack.Encoder
 	headerBuf *bytes.Buffer
-
-	logger utils.Logger
 }
 
-func newRequestWriter(logger utils.Logger) *requestWriter {
+func newRequestWriter() *requestWriter {
 	headerBuf := &bytes.Buffer{}
 	encoder := qpack.NewEncoder(headerBuf)
 	return &requestWriter{
 		encoder:   encoder,
 		headerBuf: headerBuf,
-		logger:    logger,
 	}
 }
 
@@ -69,6 +65,10 @@ func (w *requestWriter) writeHeaders(wr io.Writer, req *http.Request, gzip bool)
 	return err
 }
 
+func isExtendedConnectRequest(req *http.Request) bool {
+	return req.Method == http.MethodConnect && req.Proto != "" && req.Proto != "HTTP/1.1"
+}
+
 // copied from net/transport.go
 // Modified to support Extended CONNECT:
 // Contrary to what the godoc for the http.Request says,
@@ -87,7 +87,7 @@ func (w *requestWriter) encodeHeaders(req *http.Request, addGzipHeader bool, tra
 	}
 
 	// http.NewRequest sets this field to HTTP/1.1
-	isExtendedConnect := req.Method == http.MethodConnect && req.Proto != "" && req.Proto != "HTTP/1.1"
+	isExtendedConnect := isExtendedConnectRequest(req)
 
 	var path string
 	if req.Method != http.MethodConnect || isExtendedConnect {
@@ -215,13 +215,10 @@ func (w *requestWriter) encodeHeaders(req *http.Request, addGzipHeader bool, tra
 
 // authorityAddr returns a given authority (a host/IP, or host:port / ip:port)
 // and returns a host:port. The port 443 is added if needed.
-func authorityAddr(scheme string, authority string) (addr string) {
+func authorityAddr(authority string) (addr string) {
 	host, port, err := net.SplitHostPort(authority)
 	if err != nil { // authority didn't have a port
 		port = "443"
-		if scheme == "http" {
-			port = "80"
-		}
 		host = authority
 	}
 	if a, err := idna.ToASCII(host); err == nil {
diff --git a/http3/response_writer.go b/http3/response_writer.go
index 59f0ed9a..402938ba 100644
--- a/http3/response_writer.go
+++ b/http3/response_writer.go
@@ -1,17 +1,14 @@
 package http3
 
 import (
-	"bufio"
 	"bytes"
 	"fmt"
+	"log/slog"
 	"net/http"
 	"strconv"
 	"strings"
 	"time"
 
-	"github.com/quic-go/quic-go"
-	"github.com/quic-go/quic-go/internal/utils"
-
 	"github.com/quic-go/qpack"
 )
 
@@ -19,58 +16,27 @@ import (
 // The frame has a type and length field, both QUIC varints (maximum 8 bytes in length)
 const frameHeaderLen = 16
 
-// headerWriter wraps the stream, so that the first Write call flushes the header to the stream
-type headerWriter struct {
-	str     quic.Stream
-	header  http.Header
-	status  int // status code passed to WriteHeader
-	written bool
-
-	logger utils.Logger
-}
+const maxSmallResponseSize = 4096
 
-// writeHeader encodes and flush header to the stream
-func (hw *headerWriter) writeHeader() error {
-	var headers bytes.Buffer
-	enc := qpack.NewEncoder(&headers)
-	enc.WriteField(qpack.HeaderField{Name: ":status", Value: strconv.Itoa(hw.status)})
-
-	for k, v := range hw.header {
-		for index := range v {
-			enc.WriteField(qpack.HeaderField{Name: strings.ToLower(k), Value: v[index]})
-		}
-	}
+type responseWriter struct {
+	str *stream
 
-	buf := make([]byte, 0, frameHeaderLen+headers.Len())
-	buf = (&headersFrame{Length: uint64(headers.Len())}).Append(buf)
-	hw.logger.Infof("Responding with %d", hw.status)
-	buf = append(buf, headers.Bytes()...)
+	conn   Connection
+	header http.Header
+	buf    []byte
+	status int // status code passed to WriteHeader
 
-	_, err := hw.str.Write(buf)
-	return err
-}
+	// for responses smaller than maxSmallResponseSize, we buffer calls to Write,
+	// and automatically add the Content-Length header
+	smallResponseBuf []byte
 
-// first Write will trigger flushing header
-func (hw *headerWriter) Write(p []byte) (int, error) {
-	if !hw.written {
-		if err := hw.writeHeader(); err != nil {
-			return 0, err
-		}
-		hw.written = true
-	}
-	return hw.str.Write(p)
-}
+	contentLen     int64 // if handler set valid Content-Length header
+	numWritten     int64 // bytes written
+	headerComplete bool  // set once WriteHeader is called with a status code >= 200
+	headerWritten  bool  // set once the response header has been serialized to the stream
+	isHead         bool
 
-type responseWriter struct {
-	*headerWriter
-	conn        quic.Connection
-	bufferedStr *bufio.Writer
-	buf         []byte
-
-	contentLen    int64 // if handler set valid Content-Length header
-	numWritten    int64 // bytes written
-	headerWritten bool
-	isHead        bool
+	logger *slog.Logger
 }
 
 var (
@@ -79,18 +45,15 @@ var (
 	_ Hijacker            = &responseWriter{}
 )
 
-func newResponseWriter(str quic.Stream, conn quic.Connection, logger utils.Logger) *responseWriter {
-	hw := &headerWriter{
+func newResponseWriter(str *stream, conn Connection, isHead bool, logger *slog.Logger) *responseWriter {
+	return &responseWriter{
 		str:    str,
+		conn:   conn,
 		header: http.Header{},
+		buf:    make([]byte, frameHeaderLen),
+		isHead: isHead,
 		logger: logger,
 	}
-	return &responseWriter{
-		headerWriter: hw,
-		buf:          make([]byte, frameHeaderLen),
-		conn:         conn,
-		bufferedStr:  bufio.NewWriter(hw),
-	}
 }
 
 func (w *responseWriter) Header() http.Header {
@@ -98,7 +61,7 @@ func (w *responseWriter) Header() http.Header {
 }
 
 func (w *responseWriter) WriteHeader(status int) {
-	if w.headerWritten {
+	if w.headerComplete {
 		return
 	}
 
@@ -106,37 +69,42 @@ func (w *responseWriter) WriteHeader(status int) {
 	if status < 100 || status > 999 {
 		panic(fmt.Sprintf("invalid WriteHeader code %v", status))
 	}
+	w.status = status
 
-	if status >= 200 {
-		w.headerWritten = true
-		// Add Date header.
-		// This is what the standard library does.
-		// Can be disabled by setting the Date header to nil.
-		if _, ok := w.header["Date"]; !ok {
-			w.header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
-		}
-		// Content-Length checking
-		// use ParseUint instead of ParseInt, as negative values are invalid
-		if clen := w.header.Get("Content-Length"); clen != "" {
-			if cl, err := strconv.ParseUint(clen, 10, 63); err == nil {
-				w.contentLen = int64(cl)
-			} else {
-				// emit a warning for malformed Content-Length and remove it
-				w.logger.Errorf("Malformed Content-Length %s", clen)
-				w.header.Del("Content-Length")
-			}
-		}
+	// immediately write 1xx headers
+	if status < 200 {
+		w.writeHeader(status)
+		return
 	}
-	w.status = status
 
-	if !w.headerWritten {
-		w.writeHeader()
+	// We're done with headers once we write a status >= 200.
+	w.headerComplete = true
+	// Add Date header.
+	// This is what the standard library does.
+	// Can be disabled by setting the Date header to nil.
+	if _, ok := w.header["Date"]; !ok {
+		w.header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
+	}
+	// Content-Length checking
+	// use ParseUint instead of ParseInt, as negative values are invalid
+	if clen := w.header.Get("Content-Length"); clen != "" {
+		if cl, err := strconv.ParseUint(clen, 10, 63); err == nil {
+			w.contentLen = int64(cl)
+		} else {
+			// emit a warning for malformed Content-Length and remove it
+			logger := w.logger
+			if logger == nil {
+				logger = slog.Default()
+			}
+			logger.Error("Malformed Content-Length", "value", clen)
+			w.header.Del("Content-Length")
+		}
 	}
 }
 
 func (w *responseWriter) Write(p []byte) (int, error) {
 	bodyAllowed := bodyAllowedForStatus(w.status)
-	if !w.headerWritten {
+	if !w.headerComplete {
 		// If body is not allowed, we don't need to (and we can't) sniff the content type.
 		if bodyAllowed {
 			// If no content type, apply sniffing algorithm to body.
@@ -167,36 +135,92 @@ func (w *responseWriter) Write(p []byte) (int, error) {
 		return len(p), nil
 	}
 
-	df := &dataFrame{Length: uint64(len(p))}
+	if !w.headerWritten {
+		// Buffer small responses.
+		// This allows us to automatically set the Content-Length field.
+		if len(w.smallResponseBuf)+len(p) < maxSmallResponseSize {
+			w.smallResponseBuf = append(w.smallResponseBuf, p...)
+			return len(p), nil
+		}
+	}
+	return w.doWrite(p)
+}
+
+func (w *responseWriter) doWrite(p []byte) (int, error) {
+	if !w.headerWritten {
+		if err := w.writeHeader(w.status); err != nil {
+			return 0, maybeReplaceError(err)
+		}
+		w.headerWritten = true
+	}
+
+	l := uint64(len(w.smallResponseBuf) + len(p))
+	if l == 0 {
+		return 0, nil
+	}
+	df := &dataFrame{Length: l}
 	w.buf = w.buf[:0]
 	w.buf = df.Append(w.buf)
-	if _, err := w.bufferedStr.Write(w.buf); err != nil {
+	if _, err := w.str.writeUnframed(w.buf); err != nil {
 		return 0, maybeReplaceError(err)
 	}
-	n, err := w.bufferedStr.Write(p)
-	return n, maybeReplaceError(err)
+	if len(w.smallResponseBuf) > 0 {
+		if _, err := w.str.writeUnframed(w.smallResponseBuf); err != nil {
+			return 0, maybeReplaceError(err)
+		}
+		w.smallResponseBuf = nil
+	}
+	var n int
+	if len(p) > 0 {
+		var err error
+		n, err = w.str.writeUnframed(p)
+		if err != nil {
+			return n, maybeReplaceError(err)
+		}
+	}
+	return n, nil
 }
 
-func (w *responseWriter) FlushError() error {
-	if !w.headerWritten {
-		w.WriteHeader(http.StatusOK)
+func (w *responseWriter) writeHeader(status int) error {
+	var headers bytes.Buffer
+	enc := qpack.NewEncoder(&headers)
+	if err := enc.WriteField(qpack.HeaderField{Name: ":status", Value: strconv.Itoa(status)}); err != nil {
+		return err
 	}
-	if !w.written {
-		if err := w.writeHeader(); err != nil {
-			return maybeReplaceError(err)
+
+	for k, v := range w.header {
+		for index := range v {
+			if err := enc.WriteField(qpack.HeaderField{Name: strings.ToLower(k), Value: v[index]}); err != nil {
+				return err
+			}
 		}
-		w.written = true
 	}
-	return w.bufferedStr.Flush()
+
+	buf := make([]byte, 0, frameHeaderLen+headers.Len())
+	buf = (&headersFrame{Length: uint64(headers.Len())}).Append(buf)
+	buf = append(buf, headers.Bytes()...)
+
+	_, err := w.str.writeUnframed(buf)
+	return err
+}
+
+func (w *responseWriter) FlushError() error {
+	if !w.headerComplete {
+		w.WriteHeader(http.StatusOK)
+	}
+	_, err := w.doWrite(nil)
+	return err
 }
 
 func (w *responseWriter) Flush() {
 	if err := w.FlushError(); err != nil {
-		w.logger.Errorf("could not flush to stream: %s", err.Error())
+		if w.logger != nil {
+			w.logger.Debug("could not flush to stream", "error", err)
+		}
 	}
 }
 
-func (w *responseWriter) StreamCreator() StreamCreator {
+func (w *responseWriter) Connection() Connection {
 	return w.conn
 }
 
diff --git a/http3/roundtrip.go b/http3/roundtrip.go
index 8308cdc8..e25c2dac 100644
--- a/http3/roundtrip.go
+++ b/http3/roundtrip.go
@@ -15,6 +15,7 @@ import (
 	"golang.org/x/net/http/httpguts"
 
 	"github.com/quic-go/quic-go"
+	"github.com/quic-go/quic-go/internal/protocol"
 )
 
 // Settings are HTTP/3 settings that apply to the underlying connection.
@@ -32,40 +33,36 @@ type RoundTripOpt struct {
 	// OnlyCachedConn controls whether the RoundTripper may create a new QUIC connection.
 	// If set true and no cached connection is available, RoundTripOpt will return ErrNoCachedConn.
 	OnlyCachedConn bool
-	// DontCloseRequestStream controls whether the request stream is closed after sending the request.
-	// If set, context cancellations have no effect after the response headers are received.
-	DontCloseRequestStream bool
-	// CheckSettings is run before the request is sent to the server.
-	// If not yet received, it blocks until the server's SETTINGS frame is received.
-	// If an error is returned, the request won't be sent to the server, and the error is returned.
-	CheckSettings func(Settings) error
 }
 
-type roundTripCloser interface {
-	RoundTripOpt(*http.Request, RoundTripOpt) (*http.Response, error)
-	HandshakeComplete() bool
-	io.Closer
+type singleRoundTripper interface {
+	OpenRequestStream(context.Context) (RequestStream, error)
+	RoundTrip(*http.Request) (*http.Response, error)
 }
 
-type roundTripCloserWithCount struct {
-	roundTripCloser
+type roundTripperWithCount struct {
+	cancel  context.CancelFunc
+	dialing chan struct{} // closed as soon as quic.Dial(Early) returned
+	dialErr error
+	conn    quic.EarlyConnection
+	rt      singleRoundTripper
+
 	useCount atomic.Int64
 }
 
+func (r *roundTripperWithCount) Close() error {
+	r.cancel()
+	<-r.dialing
+	if r.conn != nil {
+		return r.conn.CloseWithError(0, "")
+	}
+	return nil
+}
+
 // RoundTripper implements the http.RoundTripper interface
 type RoundTripper struct {
 	mutex sync.Mutex
 
-	// DisableCompression, if true, prevents the Transport from
-	// requesting compression with an "Accept-Encoding: gzip"
-	// request header when the Request contains no existing
-	// Accept-Encoding value. If the Transport requests gzip on
-	// its own and gets a gzipped response, it's transparently
-	// decoded in the Response.Body. However, if the user
-	// explicitly requested gzip it is not automatically
-	// uncompressed.
-	DisableCompression bool
-
 	// TLSClientConfig specifies the TLS configuration to use with
 	// tls.Client. If nil, the default configuration is used.
 	TLSClientConfig *tls.Config
@@ -74,41 +71,38 @@ type RoundTripper struct {
 	// If nil, reasonable default values will be used.
 	QUICConfig *quic.Config
 
+	// Dial specifies an optional dial function for creating QUIC
+	// connections for requests.
+	// If Dial is nil, a UDPConn will be created at the first request
+	// and will be reused for subsequent connections to other servers.
+	Dial func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error)
+
 	// Enable support for HTTP/3 datagrams (RFC 9297).
 	// If a QUICConfig is set, datagram support also needs to be enabled on the QUIC layer by setting EnableDatagrams.
 	EnableDatagrams bool
 
 	// Additional HTTP/3 settings.
-	// It is invalid to specify any settings defined by the HTTP/3 draft and the datagram draft.
+	// It is invalid to specify any settings defined by RFC 9114 (HTTP/3) and RFC 9297 (HTTP Datagrams).
 	AdditionalSettings map[uint64]uint64
 
-	// When set, this callback is called for the first unknown frame parsed on a bidirectional stream.
-	// It is called right after parsing the frame type.
-	// If parsing the frame type fails, the error is passed to the callback.
-	// In that case, the frame type will not be set.
-	// Callers can either ignore the frame and return control of the stream back to HTTP/3
-	// (by returning hijacked false).
-	// Alternatively, callers can take over the QUIC stream (by returning hijacked true).
-	StreamHijacker func(FrameType, quic.Connection, quic.Stream, error) (hijacked bool, err error)
-
-	// When set, this callback is called for unknown unidirectional stream of unknown stream type.
-	// If parsing the stream type fails, the error is passed to the callback.
-	// In that case, the stream type will not be set.
-	UniStreamHijacker func(StreamType, quic.Connection, quic.ReceiveStream, error) (hijacked bool)
-
-	// Dial specifies an optional dial function for creating QUIC
-	// connections for requests.
-	// If Dial is nil, a UDPConn will be created at the first request
-	// and will be reused for subsequent connections to other servers.
-	Dial func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error)
-
 	// MaxResponseHeaderBytes specifies a limit on how many response bytes are
 	// allowed in the server's response header.
 	// Zero means to use a default limit.
 	MaxResponseHeaderBytes int64
 
-	newClient func(hostname string, tlsConf *tls.Config, opts *roundTripperOpts, conf *quic.Config, dialer dialFunc) (roundTripCloser, error) // so we can mock it in tests
-	clients   map[string]*roundTripCloserWithCount
+	// DisableCompression, if true, prevents the Transport from requesting compression with an
+	// "Accept-Encoding: gzip" request header when the Request contains no existing Accept-Encoding value.
+	// If the Transport requests gzip on its own and gets a gzipped response, it's transparently
+	// decoded in the Response.Body.
+	// However, if the user explicitly requested gzip it is not automatically uncompressed.
+	DisableCompression bool
+
+	initOnce sync.Once
+	initErr  error
+
+	newClient func(quic.EarlyConnection) singleRoundTripper
+
+	clients   map[string]*roundTripperWithCount
 	transport *quic.Transport
 }
 
@@ -122,6 +116,11 @@ var ErrNoCachedConn = errors.New("http3: no cached connection was available")
 
 // RoundTripOpt is like RoundTrip, but takes options.
 func (r *RoundTripper) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
+	r.initOnce.Do(func() { r.initErr = r.init() })
+	if r.initErr != nil {
+		return nil, r.initErr
+	}
+
 	if req.URL == nil {
 		closeRequestBody(req)
 		return nil, errors.New("http3: nil Request.URL")
@@ -154,15 +153,31 @@ func (r *RoundTripper) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.
 		return nil, fmt.Errorf("http3: invalid method %q", req.Method)
 	}
 
-	hostname := authorityAddr("https", hostnameFromRequest(req))
-	cl, isReused, err := r.getClient(hostname, opt.OnlyCachedConn)
+	hostname := authorityAddr(hostnameFromURL(req.URL))
+	cl, isReused, err := r.getClient(req.Context(), hostname, opt.OnlyCachedConn)
 	if err != nil {
 		return nil, err
 	}
+
+	select {
+	case <-cl.dialing:
+	case <-req.Context().Done():
+		return nil, context.Cause(req.Context())
+	}
+
+	if cl.dialErr != nil {
+		return nil, cl.dialErr
+	}
 	defer cl.useCount.Add(-1)
-	rsp, err := cl.RoundTripOpt(req, opt)
+	rsp, err := cl.rt.RoundTrip(req)
 	if err != nil {
-		r.removeClient(hostname)
+		// non-nil errors on roundtrip are likely due to a problem with the connection
+		// so we remove the client from the cache so that subsequent trips reconnect
+		// context cancelation is excluded as is does not signify a connection error
+		if !errors.Is(err, context.Canceled) {
+			r.removeClient(hostname)
+		}
+
 		if isReused {
 			if nerr, ok := err.(net.Error); ok && nerr.Timeout() {
 				return r.RoundTripOpt(req, opt)
@@ -177,59 +192,126 @@ func (r *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
 	return r.RoundTripOpt(req, RoundTripOpt{})
 }
 
-func (r *RoundTripper) getClient(hostname string, onlyCached bool) (rtc *roundTripCloserWithCount, isReused bool, err error) {
+func (r *RoundTripper) init() error {
+	if r.newClient == nil {
+		r.newClient = func(conn quic.EarlyConnection) singleRoundTripper {
+			return &SingleDestinationRoundTripper{
+				Connection:             conn,
+				EnableDatagrams:        r.EnableDatagrams,
+				DisableCompression:     r.DisableCompression,
+				AdditionalSettings:     r.AdditionalSettings,
+				MaxResponseHeaderBytes: r.MaxResponseHeaderBytes,
+			}
+		}
+	}
+	if r.QUICConfig == nil {
+		r.QUICConfig = defaultQuicConfig.Clone()
+		r.QUICConfig.EnableDatagrams = r.EnableDatagrams
+	}
+	if r.EnableDatagrams && !r.QUICConfig.EnableDatagrams {
+		return errors.New("HTTP Datagrams enabled, but QUIC Datagrams disabled")
+	}
+	if len(r.QUICConfig.Versions) == 0 {
+		r.QUICConfig = r.QUICConfig.Clone()
+		r.QUICConfig.Versions = []quic.Version{protocol.SupportedVersions[0]}
+	}
+	if len(r.QUICConfig.Versions) != 1 {
+		return errors.New("can only use a single QUIC version for dialing a HTTP/3 connection")
+	}
+	if r.QUICConfig.MaxIncomingStreams == 0 {
+		r.QUICConfig.MaxIncomingStreams = -1 // don't allow any bidirectional streams
+	}
+	return nil
+}
+
+func (r *RoundTripper) getClient(ctx context.Context, hostname string, onlyCached bool) (rtc *roundTripperWithCount, isReused bool, err error) {
 	r.mutex.Lock()
 	defer r.mutex.Unlock()
 
 	if r.clients == nil {
-		r.clients = make(map[string]*roundTripCloserWithCount)
+		r.clients = make(map[string]*roundTripperWithCount)
 	}
 
-	client, ok := r.clients[hostname]
+	cl, ok := r.clients[hostname]
 	if !ok {
 		if onlyCached {
 			return nil, false, ErrNoCachedConn
 		}
-		var err error
-		newCl := newClient
-		if r.newClient != nil {
-			newCl = r.newClient
+		ctx, cancel := context.WithCancel(ctx)
+		cl = &roundTripperWithCount{
+			dialing: make(chan struct{}),
+			cancel:  cancel,
 		}
-		dial := r.Dial
-		if dial == nil {
-			if r.transport == nil {
-				udpConn, err := net.ListenUDP("udp", nil)
-				if err != nil {
-					return nil, false, err
-				}
-				r.transport = &quic.Transport{Conn: udpConn}
+		go func() {
+			defer close(cl.dialing)
+			defer cancel()
+			conn, rt, err := r.dial(ctx, hostname)
+			if err != nil {
+				cl.dialErr = err
+				return
 			}
-			dial = r.makeDialer()
+			cl.conn = conn
+			cl.rt = rt
+		}()
+		r.clients[hostname] = cl
+	}
+	select {
+	case <-cl.dialing:
+		if cl.dialErr != nil {
+			return nil, false, cl.dialErr
+		}
+		select {
+		case <-cl.conn.HandshakeComplete():
+			isReused = true
+		default:
 		}
-		c, err := newCl(
-			hostname,
-			r.TLSClientConfig,
-			&roundTripperOpts{
-				EnableDatagram:     r.EnableDatagrams,
-				DisableCompression: r.DisableCompression,
-				MaxHeaderBytes:     r.MaxResponseHeaderBytes,
-				StreamHijacker:     r.StreamHijacker,
-				UniStreamHijacker:  r.UniStreamHijacker,
-				AdditionalSettings: r.AdditionalSettings,
-			},
-			r.QUICConfig,
-			dial,
-		)
+	default:
+	}
+	cl.useCount.Add(1)
+	return cl, isReused, nil
+}
+
+func (r *RoundTripper) dial(ctx context.Context, hostname string) (quic.EarlyConnection, singleRoundTripper, error) {
+	var tlsConf *tls.Config
+	if r.TLSClientConfig == nil {
+		tlsConf = &tls.Config{}
+	} else {
+		tlsConf = r.TLSClientConfig.Clone()
+	}
+	if tlsConf.ServerName == "" {
+		sni, _, err := net.SplitHostPort(hostname)
 		if err != nil {
-			return nil, false, err
+			// It's ok if net.SplitHostPort returns an error - it could be a hostname/IP address without a port.
+			sni = hostname
+		}
+		tlsConf.ServerName = sni
+	}
+	// Replace existing ALPNs by H3
+	tlsConf.NextProtos = []string{versionToALPN(r.QUICConfig.Versions[0])}
+
+	dial := r.Dial
+	if dial == nil {
+		if r.transport == nil {
+			udpConn, err := net.ListenUDP("udp", nil)
+			if err != nil {
+				return nil, nil, err
+			}
+			r.transport = &quic.Transport{Conn: udpConn}
+		}
+		dial = func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
+			udpAddr, err := net.ResolveUDPAddr("udp", addr)
+			if err != nil {
+				return nil, err
+			}
+			return r.transport.DialEarly(ctx, udpAddr, tlsCfg, cfg)
 		}
-		client = &roundTripCloserWithCount{roundTripCloser: c}
-		r.clients[hostname] = client
-	} else if client.HandshakeComplete() {
-		isReused = true
 	}
-	client.useCount.Add(1)
-	return client, isReused, nil
+
+	conn, err := dial(ctx, hostname, tlsConf, r.QUICConfig)
+	if err != nil {
+		return nil, nil, err
+	}
+	return conn, r.newClient(conn), nil
 }
 
 func (r *RoundTripper) removeClient(hostname string) {
@@ -246,8 +328,8 @@ func (r *RoundTripper) removeClient(hostname string) {
 func (r *RoundTripper) Close() error {
 	r.mutex.Lock()
 	defer r.mutex.Unlock()
-	for _, client := range r.clients {
-		if err := client.Close(); err != nil {
+	for _, cl := range r.clients {
+		if err := cl.Close(); err != nil {
 			return err
 		}
 	}
@@ -292,23 +374,12 @@ func isNotToken(r rune) bool {
 	return !httpguts.IsTokenRune(r)
 }
 
-// makeDialer makes a QUIC dialer using r.udpConn.
-func (r *RoundTripper) makeDialer() func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
-	return func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
-		udpAddr, err := net.ResolveUDPAddr("udp", addr)
-		if err != nil {
-			return nil, err
-		}
-		return r.transport.DialEarly(ctx, udpAddr, tlsCfg, cfg)
-	}
-}
-
 func (r *RoundTripper) CloseIdleConnections() {
 	r.mutex.Lock()
 	defer r.mutex.Unlock()
-	for hostname, client := range r.clients {
-		if client.useCount.Load() == 0 {
-			client.Close()
+	for hostname, cl := range r.clients {
+		if cl.useCount.Load() == 0 {
+			cl.Close()
 			delete(r.clients, hostname)
 		}
 	}
diff --git a/http3/server.go b/http3/server.go
index 6ce186e7..37cf1956 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -6,6 +6,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"log/slog"
 	"net"
 	"net/http"
 	"runtime"
@@ -16,7 +17,6 @@ import (
 
 	"github.com/quic-go/quic-go"
 	"github.com/quic-go/quic-go/internal/protocol"
-	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 
 	"github.com/quic-go/qpack"
@@ -30,7 +30,6 @@ var (
 	quicListenAddr = func(addr string, tlsConf *tls.Config, config *quic.Config) (QUICEarlyListener, error) {
 		return quic.ListenAddrEarly(addr, tlsConf, config)
 	}
-	errPanicked = errors.New("panicked")
 )
 
 // NextProtoH3 is the ALPN protocol negotiated during the TLS handshake, for QUIC v1 and v2.
@@ -126,20 +125,6 @@ var ServerContextKey = &contextKey{"http3-server"}
 // than its string representation.
 var RemoteAddrContextKey = &contextKey{"remote-addr"}
 
-type requestError struct {
-	err       error
-	streamErr ErrCode
-	connErr   ErrCode
-}
-
-func newStreamError(code ErrCode, err error) requestError {
-	return requestError{err: err, streamErr: code}
-}
-
-func newConnError(code ErrCode, err error) requestError {
-	return requestError{err: err, connErr: code}
-}
-
 // listenerInfo contains info about specific listener added with addListener
 type listenerInfo struct {
 	port int // 0 means that no info about port is available
@@ -181,9 +166,8 @@ type Server struct {
 	// http.NotFound.
 	Handler http.Handler
 
-	// EnableDatagrams enables support for HTTP/3 datagrams.
+	// EnableDatagrams enables support for HTTP/3 datagrams (RFC 9297).
 	// If set to true, QUICConfig.EnableDatagram will be set.
-	// See https://datatracker.ietf.org/doc/html/rfc9297.
 	EnableDatagrams bool
 
 	// MaxHeaderBytes controls the maximum number of bytes the server will
@@ -193,7 +177,7 @@ type Server struct {
 	MaxHeaderBytes int
 
 	// AdditionalSettings specifies additional HTTP/3 settings.
-	// It is invalid to specify any settings defined by the HTTP/3 draft and the datagram draft.
+	// It is invalid to specify any settings defined by RFC 9114 (HTTP/3) and RFC 9297 (HTTP Datagrams).
 	AdditionalSettings map[uint64]uint64
 
 	// StreamHijacker, when set, is called for the first unknown frame parsed on a bidirectional stream.
@@ -203,26 +187,26 @@ type Server struct {
 	// Callers can either ignore the frame and return control of the stream back to HTTP/3
 	// (by returning hijacked false).
 	// Alternatively, callers can take over the QUIC stream (by returning hijacked true).
-	StreamHijacker func(FrameType, quic.Connection, quic.Stream, error) (hijacked bool, err error)
+	StreamHijacker func(FrameType, quic.ConnectionTracingID, quic.Stream, error) (hijacked bool, err error)
 
 	// UniStreamHijacker, when set, is called for unknown unidirectional stream of unknown stream type.
 	// If parsing the stream type fails, the error is passed to the callback.
 	// In that case, the stream type will not be set.
-	UniStreamHijacker func(StreamType, quic.Connection, quic.ReceiveStream, error) (hijacked bool)
+	UniStreamHijacker func(StreamType, quic.ConnectionTracingID, quic.ReceiveStream, error) (hijacked bool)
 
 	// ConnContext optionally specifies a function that modifies
 	// the context used for a new connection c. The provided ctx
 	// has a ServerContextKey value.
 	ConnContext func(ctx context.Context, c quic.Connection) context.Context
 
+	Logger *slog.Logger
+
 	mutex     sync.RWMutex
 	listeners map[*QUICEarlyListener]listenerInfo
 
 	closed bool
 
 	altSvcHeader string
-
-	logger utils.Logger
 }
 
 // ListenAndServe listens on the UDP address s.Addr and calls s.Handler to handle HTTP/3 requests on incoming connections.
@@ -259,12 +243,6 @@ func (s *Server) Serve(conn net.PacketConn) error {
 
 // ServeQUICConn serves a single QUIC connection.
 func (s *Server) ServeQUICConn(conn quic.Connection) error {
-	s.mutex.Lock()
-	if s.logger == nil {
-		s.logger = utils.DefaultLogger.WithPrefix("server")
-	}
-	s.mutex.Unlock()
-
 	return s.handleConn(conn)
 }
 
@@ -288,7 +266,9 @@ func (s *Server) ServeListener(ln QUICEarlyListener) error {
 		}
 		go func() {
 			if err := s.handleConn(conn); err != nil {
-				s.logger.Debugf("handling connection failed: %s", err)
+				if s.Logger != nil {
+					s.Logger.Debug("handling connection failed", "error", err)
+				}
 			}
 		}()
 	}
@@ -415,9 +395,6 @@ func (s *Server) addListener(l *QUICEarlyListener) error {
 	if s.closed {
 		return http.ErrServerClosed
 	}
-	if s.logger == nil {
-		s.logger = utils.DefaultLogger.WithPrefix("server")
-	}
 	if s.listeners == nil {
 		s.listeners = make(map[*QUICEarlyListener]listenerInfo)
 	}
@@ -426,7 +403,11 @@ func (s *Server) addListener(l *QUICEarlyListener) error {
 	if port, err := extractPort(laddr.String()); err == nil {
 		s.listeners[l] = listenerInfo{port}
 	} else {
-		s.logger.Errorf("Unable to extract port from listener %s, will not be announced using SetQUICHeaders: %s", laddr, err)
+		logger := s.Logger
+		if logger == nil {
+			logger = slog.Default()
+		}
+		logger.Error("Unable to extract port from listener, will not be announced using SetQUICHeaders", "local addr", laddr, "error", err)
 		s.listeners[l] = listenerInfo{}
 	}
 	s.generateAltSvcHeader()
@@ -462,7 +443,7 @@ func (s *Server) handleConn(conn quic.Connection) error {
 		s.EnableDatagrams,
 		s.UniStreamHijacker,
 		protocol.PerspectiveServer,
-		s.logger,
+		s.Logger,
 	)
 	go hconn.HandleUnidirectionalStreams()
 
@@ -477,29 +458,7 @@ func (s *Server) handleConn(conn quic.Connection) error {
 			}
 			return fmt.Errorf("accepting stream failed: %w", err)
 		}
-		go func() {
-			rerr := s.handleRequest(conn, str, decoder, func() {
-				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "")
-			})
-			if rerr.err == errHijacked {
-				return
-			}
-			if rerr.err != nil || rerr.streamErr != 0 || rerr.connErr != 0 {
-				s.logger.Debugf("Handling request failed: %s", err)
-				if rerr.streamErr != 0 {
-					str.CancelWrite(quic.StreamErrorCode(rerr.streamErr))
-				}
-				if rerr.connErr != 0 {
-					var reason string
-					if rerr.err != nil {
-						reason = rerr.err.Error()
-					}
-					conn.CloseWithError(quic.ApplicationErrorCode(rerr.connErr), reason)
-				}
-				return
-			}
-			str.Close()
-		}()
+		go s.handleRequest(hconn, str, decoder)
 	}
 }
 
@@ -510,37 +469,53 @@ func (s *Server) maxHeaderBytes() uint64 {
 	return uint64(s.MaxHeaderBytes)
 }
 
-func (s *Server) handleRequest(conn quic.Connection, str quic.Stream, decoder *qpack.Decoder, onFrameError func()) requestError {
+func (s *Server) handleRequest(conn *connection, str quic.Stream, decoder *qpack.Decoder) {
 	var ufh unknownFrameHandlerFunc
 	if s.StreamHijacker != nil {
-		ufh = func(ft FrameType, e error) (processed bool, err error) { return s.StreamHijacker(ft, conn, str, e) }
+		ufh = func(ft FrameType, e error) (processed bool, err error) {
+			return s.StreamHijacker(
+				ft,
+				conn.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID),
+				str,
+				e,
+			)
+		}
 	}
 	frame, err := parseNextFrame(str, ufh)
 	if err != nil {
-		if err == errHijacked {
-			return requestError{err: errHijacked}
+		if !errors.Is(err, errHijacked) {
+			str.CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+			str.CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 		}
-		return newStreamError(ErrCodeRequestIncomplete, err)
+		return
 	}
 	hf, ok := frame.(*headersFrame)
 	if !ok {
-		return newConnError(ErrCodeFrameUnexpected, errors.New("expected first frame to be a HEADERS frame"))
+		conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "expected first frame to be a HEADERS frame")
+		return
 	}
 	if hf.Length > s.maxHeaderBytes() {
-		return newStreamError(ErrCodeFrameError, fmt.Errorf("HEADERS frame too large: %d bytes (max: %d)", hf.Length, s.maxHeaderBytes()))
+		str.CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
+		str.CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
+		return
 	}
 	headerBlock := make([]byte, hf.Length)
 	if _, err := io.ReadFull(str, headerBlock); err != nil {
-		return newStreamError(ErrCodeRequestIncomplete, err)
+		str.CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+		str.CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
+		return
 	}
 	hfs, err := decoder.DecodeFull(headerBlock)
 	if err != nil {
 		// TODO: use the right error code
-		return newConnError(ErrCodeGeneralProtocolError, err)
+		conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeGeneralProtocolError), "expected first frame to be a HEADERS frame")
+		return
 	}
 	req, err := requestFromHeaders(hfs)
 	if err != nil {
-		return newStreamError(ErrCodeMessageError, err)
+		str.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
+		str.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
+		return
 	}
 
 	connState := conn.ConnectionState().TLS
@@ -549,19 +524,16 @@ func (s *Server) handleRequest(conn quic.Connection, str quic.Stream, decoder *q
 
 	// Check that the client doesn't send more data in DATA frames than indicated by the Content-Length header (if set).
 	// See section 4.1.2 of RFC 9114.
-	var httpStr Stream
+	contentLength := int64(-1)
 	if _, ok := req.Header["Content-Length"]; ok && req.ContentLength >= 0 {
-		httpStr = newLengthLimitedStream(newStream(str, onFrameError), req.ContentLength)
-	} else {
-		httpStr = newStream(str, onFrameError)
+		contentLength = req.ContentLength
 	}
-	body := newRequestBody(httpStr)
+	hstr := newStream(str, conn)
+	body := newRequestBody(hstr, contentLength, conn.Context(), conn.ReceivedSettings(), conn.Settings)
 	req.Body = body
 
-	if s.logger.Debug() {
-		s.logger.Infof("%s %s%s, on stream %d", req.Method, req.Host, req.RequestURI, str.StreamID())
-	} else {
-		s.logger.Infof("%s %s%s", req.Method, req.Host, req.RequestURI)
+	if s.Logger != nil {
+		s.Logger.Debug("handling request", "method", req.Method, "host", req.Host, "uri", req.RequestURI)
 	}
 
 	ctx := str.Context()
@@ -575,10 +547,7 @@ func (s *Server) handleRequest(conn quic.Connection, str quic.Stream, decoder *q
 		}
 	}
 	req = req.WithContext(ctx)
-	r := newResponseWriter(str, conn, s.logger)
-	if req.Method == http.MethodHead {
-		r.isHead = true
-	}
+	r := newResponseWriter(hstr, conn, req.Method == http.MethodHead, s.Logger)
 	handler := s.Handler
 	if handler == nil {
 		handler = http.DefaultServeMux
@@ -596,34 +565,42 @@ func (s *Server) handleRequest(conn quic.Connection, str quic.Stream, decoder *q
 				const size = 64 << 10
 				buf := make([]byte, size)
 				buf = buf[:runtime.Stack(buf, false)]
-				s.logger.Errorf("http: panic serving: %v\n%s", p, buf)
+				logger := s.Logger
+				if logger == nil {
+					logger = slog.Default()
+				}
+				logger.Error("http: panic serving", "arg", p, "trace", buf)
 			}
 		}()
 		handler.ServeHTTP(r, req)
 	}()
 
 	if body.wasStreamHijacked() {
-		return requestError{err: errHijacked}
+		return
 	}
 
 	// only write response when there is no panic
 	if !panicked {
 		// response not written to the client yet, set Content-Length
-		if !r.written {
+		if !r.headerWritten {
 			if _, haveCL := r.header["Content-Length"]; !haveCL {
 				r.header.Set("Content-Length", strconv.FormatInt(r.numWritten, 10))
 			}
 		}
 		r.Flush()
 	}
-	// If the EOF was read by the handler, CancelRead() is a no-op.
-	str.CancelRead(quic.StreamErrorCode(ErrCodeNoError))
 
 	// abort the stream when there is a panic
 	if panicked {
-		return newStreamError(ErrCodeInternalError, errPanicked)
+		str.CancelRead(quic.StreamErrorCode(ErrCodeInternalError))
+		str.CancelWrite(quic.StreamErrorCode(ErrCodeInternalError))
+		return
 	}
-	return requestError{}
+
+	// If the EOF was read by the handler, CancelRead() is a no-op.
+	str.CancelRead(quic.StreamErrorCode(ErrCodeNoError))
+
+	str.Close()
 }
 
 // Close the server immediately, aborting requests and sending CONNECTION_CLOSE frames to connected clients.
diff --git a/interface.go b/interface.go
index ca8544d8..6f906fff 100644
--- a/interface.go
+++ b/interface.go
@@ -59,6 +59,9 @@ var Err0RTTRejected = errors.New("0-RTT rejected")
 // as well as on the context passed to logging.Tracer.NewConnectionTracer.
 var ConnectionTracingKey = connTracingCtxKey{}
 
+// ConnectionTracingID is the type of the context value saved under the ConnectionTracingKey.
+type ConnectionTracingID uint64
+
 type connTracingCtxKey struct{}
 
 // QUICVersionContextKey can be used to find out the QUIC version of a TLS handshake from the
@@ -121,7 +124,9 @@ type SendStream interface {
 	// CancelWrite aborts sending on this stream.
 	// Data already written, but not yet delivered to the peer is not guaranteed to be delivered reliably.
 	// Write will unblock immediately, and future calls to Write will fail.
-	// When called multiple times or after closing the stream it is a no-op.
+	// When called multiple times it is a no-op.
+	// When called after Close, it aborts delivery. Note that there is no guarantee if
+	// the peer will receive the FIN or the reset first.
 	CancelWrite(StreamErrorCode)
 	// The Context is canceled as soon as the write-side of the stream is closed.
 	// This happens when Close() or CancelWrite() is called, or when the peer
diff --git a/internal/protocol/params.go b/internal/protocol/params.go
index 487cbc06..a08deeaa 100644
--- a/internal/protocol/params.go
+++ b/internal/protocol/params.go
@@ -3,10 +3,10 @@ package protocol
 import "time"
 
 // DesiredReceiveBufferSize is the kernel UDP receive buffer size that we'd like to use.
-const DesiredReceiveBufferSize = (1 << 20) * 2 // 2 MB
+const DesiredReceiveBufferSize = (1 << 20) * 7 // 7 MB
 
 // DesiredSendBufferSize is the kernel UDP send buffer size that we'd like to use.
-const DesiredSendBufferSize = (1 << 20) * 2 // 2 MB
+const DesiredSendBufferSize = (1 << 20) * 7 // 7 MB
 
 // InitialPacketSizeIPv4 is the maximum packet size that we use for sending IPv4 packets.
 const InitialPacketSizeIPv4 = 1252
diff --git a/send_stream.go b/send_stream.go
index e1ce3e67..7f9b0eb4 100644
--- a/send_stream.go
+++ b/send_stream.go
@@ -60,6 +60,7 @@ var (
 )
 
 func newSendStream(
+	ctx context.Context,
 	streamID protocol.StreamID,
 	sender streamSender,
 	flowController flowcontrol.StreamFlowController,
@@ -71,7 +72,7 @@ func newSendStream(
 		writeChan:      make(chan struct{}, 1),
 		writeOnce:      make(chan struct{}, 1), // cap: 1, to protect against concurrent use of Write
 	}
-	s.ctx, s.ctxCancel = context.WithCancelCause(context.Background())
+	s.ctx, s.ctxCancel = context.WithCancelCause(ctx)
 	return s
 }
 
diff --git a/server.go b/server.go
index afbd18fd..2411714e 100644
--- a/server.go
+++ b/server.go
@@ -92,7 +92,7 @@ type baseServer struct {
 		*handshake.TokenGenerator,
 		bool, /* client address validated by an address validation token */
 		*logging.ConnectionTracer,
-		uint64,
+		ConnectionTracingID,
 		utils.Logger,
 		protocol.Version,
 	) quicConn
diff --git a/stream.go b/stream.go
index ce4374d6..d6c809ad 100644
--- a/stream.go
+++ b/stream.go
@@ -1,6 +1,7 @@
 package quic
 
 import (
+	"context"
 	"net"
 	"os"
 	"sync"
@@ -85,7 +86,9 @@ type stream struct {
 var _ Stream = &stream{}
 
 // newStream creates a new Stream
-func newStream(streamID protocol.StreamID,
+func newStream(
+	ctx context.Context,
+	streamID protocol.StreamID,
 	sender streamSender,
 	flowController flowcontrol.StreamFlowController,
 ) *stream {
@@ -99,7 +102,7 @@ func newStream(streamID protocol.StreamID,
 			s.completedMutex.Unlock()
 		},
 	}
-	s.sendStream = *newSendStream(streamID, senderForSendStream, flowController)
+	s.sendStream = *newSendStream(ctx, streamID, senderForSendStream, flowController)
 	senderForReceiveStream := &uniStreamSender{
 		streamSender: sender,
 		onStreamCompletedImpl: func() {
diff --git a/streams_map.go b/streams_map.go
index b1a80eb3..0ba23b25 100644
--- a/streams_map.go
+++ b/streams_map.go
@@ -45,6 +45,7 @@ func (streamOpenErr) Timeout() bool     { return false }
 var errTooManyOpenStreams = errors.New("too many open streams")
 
 type streamsMap struct {
+	ctx         context.Context // not used for cancellations, but carries the values associated with the connection
 	perspective protocol.Perspective
 
 	maxIncomingBidiStreams uint64
@@ -64,6 +65,7 @@ type streamsMap struct {
 var _ streamManager = &streamsMap{}
 
 func newStreamsMap(
+	ctx context.Context,
 	sender streamSender,
 	newFlowController func(protocol.StreamID) flowcontrol.StreamFlowController,
 	maxIncomingBidiStreams uint64,
@@ -71,6 +73,7 @@ func newStreamsMap(
 	perspective protocol.Perspective,
 ) streamManager {
 	m := &streamsMap{
+		ctx:                    ctx,
 		perspective:            perspective,
 		newFlowController:      newFlowController,
 		maxIncomingBidiStreams: maxIncomingBidiStreams,
@@ -86,7 +89,7 @@ func (m *streamsMap) initMaps() {
 		protocol.StreamTypeBidi,
 		func(num protocol.StreamNum) streamI {
 			id := num.StreamID(protocol.StreamTypeBidi, m.perspective)
-			return newStream(id, m.sender, m.newFlowController(id))
+			return newStream(m.ctx, id, m.sender, m.newFlowController(id))
 		},
 		m.sender.queueControlFrame,
 	)
@@ -94,7 +97,7 @@ func (m *streamsMap) initMaps() {
 		protocol.StreamTypeBidi,
 		func(num protocol.StreamNum) streamI {
 			id := num.StreamID(protocol.StreamTypeBidi, m.perspective.Opposite())
-			return newStream(id, m.sender, m.newFlowController(id))
+			return newStream(m.ctx, id, m.sender, m.newFlowController(id))
 		},
 		m.maxIncomingBidiStreams,
 		m.sender.queueControlFrame,
@@ -103,7 +106,7 @@ func (m *streamsMap) initMaps() {
 		protocol.StreamTypeUni,
 		func(num protocol.StreamNum) sendStreamI {
 			id := num.StreamID(protocol.StreamTypeUni, m.perspective)
-			return newSendStream(id, m.sender, m.newFlowController(id))
+			return newSendStream(m.ctx, id, m.sender, m.newFlowController(id))
 		},
 		m.sender.queueControlFrame,
 	)


Test patch:
diff --git a/client_test.go b/client_test.go
index 697abcae..5a382e51 100644
--- a/client_test.go
+++ b/client_test.go
@@ -44,7 +44,7 @@ var _ = Describe("Client", func() {
 			enable0RTT bool,
 			hasNegotiatedVersion bool,
 			tracer *logging.ConnectionTracer,
-			tracingID uint64,
+			tracingID ConnectionTracingID,
 			logger utils.Logger,
 			v protocol.Version,
 		) quicConn
@@ -123,7 +123,7 @@ var _ = Describe("Client", func() {
 				enable0RTT bool,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
@@ -160,7 +160,7 @@ var _ = Describe("Client", func() {
 				enable0RTT bool,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
@@ -197,7 +197,7 @@ var _ = Describe("Client", func() {
 				_ bool,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
@@ -282,7 +282,7 @@ var _ = Describe("Client", func() {
 				_ bool,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				versionP protocol.Version,
 			) quicConn {
@@ -325,7 +325,7 @@ var _ = Describe("Client", func() {
 				_ bool,
 				hasNegotiatedVersion bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				versionP protocol.Version,
 			) quicConn {
diff --git a/connection_test.go b/connection_test.go
index fe318d22..c6d9d24e 100644
--- a/connection_test.go
+++ b/connection_test.go
@@ -3167,7 +3167,7 @@ var _ = Describe("Client Connection", func() {
 		// the connection to immediately break down
 		It("fails on Initial-level ACK for unsent packet", func() {
 			ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 2, Largest: 2}}}
-			initialPacket := testutils.ComposeInitialPacket(destConnID, srcConnID, destConnID, []wire.Frame{ack}, protocol.PerspectiveServer, conn.version)
+			initialPacket := testutils.ComposeInitialPacket(destConnID, srcConnID, destConnID, nil, []wire.Frame{ack}, protocol.PerspectiveServer, conn.version)
 			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 			Expect(conn.handlePacketImpl(wrapPacket(initialPacket))).To(BeFalse())
 		})
@@ -3179,7 +3179,7 @@ var _ = Describe("Client Connection", func() {
 				IsApplicationError: true,
 				ReasonPhrase:       "mitm attacker",
 			}
-			initialPacket := testutils.ComposeInitialPacket(destConnID, srcConnID, destConnID, []wire.Frame{connCloseFrame}, protocol.PerspectiveServer, conn.version)
+			initialPacket := testutils.ComposeInitialPacket(destConnID, srcConnID, destConnID, nil, []wire.Frame{connCloseFrame}, protocol.PerspectiveServer, conn.version)
 			tracer.EXPECT().ReceivedLongHeaderPacket(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any())
 			Expect(conn.handlePacketImpl(wrapPacket(initialPacket))).To(BeTrue())
 		})
@@ -3197,7 +3197,7 @@ var _ = Describe("Client Connection", func() {
 
 			tracer.EXPECT().ReceivedRetry(gomock.Any())
 			conn.handlePacketImpl(wrapPacket(testutils.ComposeRetryPacket(newSrcConnID, destConnID, destConnID, []byte("foobar"), conn.version)))
-			initialPacket := testutils.ComposeInitialPacket(conn.connIDManager.Get(), srcConnID, conn.connIDManager.Get(), nil, protocol.PerspectiveServer, conn.version)
+			initialPacket := testutils.ComposeInitialPacket(conn.connIDManager.Get(), srcConnID, conn.connIDManager.Get(), nil, nil, protocol.PerspectiveServer, conn.version)
 			tracer.EXPECT().DroppedPacket(gomock.Any(), protocol.InvalidPacketNumber, gomock.Any(), gomock.Any())
 			Expect(conn.handlePacketImpl(wrapPacket(initialPacket))).To(BeFalse())
 		})
diff --git a/http3/body_test.go b/http3/body_test.go
index 7b96345d..a348604f 100644
--- a/http3/body_test.go
+++ b/http3/body_test.go
@@ -1,7 +1,9 @@
 package http3
 
 import (
+	"bytes"
 	"errors"
+	"io"
 
 	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
@@ -19,7 +21,7 @@ var _ = Describe("Response Body", func() {
 	It("closes the reqDone channel when Read errors", func() {
 		str := mockquic.NewMockStream(mockCtrl)
 		str.EXPECT().Read(gomock.Any()).Return(0, errors.New("test error"))
-		rb := newResponseBody(str, nil, reqDone)
+		rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
 		_, err := rb.Read([]byte{0})
 		Expect(err).To(MatchError("test error"))
 		Expect(reqDone).To(BeClosed())
@@ -28,7 +30,7 @@ var _ = Describe("Response Body", func() {
 	It("allows multiple calls to Read, when Read errors", func() {
 		str := mockquic.NewMockStream(mockCtrl)
 		str.EXPECT().Read(gomock.Any()).Return(0, errors.New("test error")).Times(2)
-		rb := newResponseBody(str, nil, reqDone)
+		rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
 		_, err := rb.Read([]byte{0})
 		Expect(err).To(HaveOccurred())
 		Expect(reqDone).To(BeClosed())
@@ -38,17 +40,62 @@ var _ = Describe("Response Body", func() {
 
 	It("closes responses", func() {
 		str := mockquic.NewMockStream(mockCtrl)
-		rb := newResponseBody(str, nil, reqDone)
+		rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
 		str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled))
 		Expect(rb.Close()).To(Succeed())
 	})
 
 	It("allows multiple calls to Close", func() {
 		str := mockquic.NewMockStream(mockCtrl)
-		rb := newResponseBody(str, nil, reqDone)
+		rb := newResponseBody(&stream{Stream: str}, -1, reqDone)
 		str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).MaxTimes(2)
 		Expect(rb.Close()).To(Succeed())
 		Expect(reqDone).To(BeClosed())
 		Expect(rb.Close()).To(Succeed())
 	})
+
+	Context("length limiting", func() {
+		It("reads all frames", func() {
+			var buf bytes.Buffer
+			buf.Write(getDataFrame([]byte("foobar")))
+			str := mockquic.NewMockStream(mockCtrl)
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
+			rb := newResponseBody(&stream{Stream: str}, 6, reqDone)
+			data, err := io.ReadAll(rb)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(data).To(Equal([]byte("foobar")))
+		})
+
+		It("errors if more data than the maximum length is sent, in the middle of a frame", func() {
+			var buf bytes.Buffer
+			buf.Write(getDataFrame([]byte("foo")))
+			buf.Write(getDataFrame([]byte("bar")))
+			str := mockquic.NewMockStream(mockCtrl)
+			str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
+			str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
+			rb := newResponseBody(&stream{Stream: str}, 4, reqDone)
+			data, err := io.ReadAll(rb)
+			Expect(data).To(Equal([]byte("foob")))
+			Expect(err).To(MatchError(errTooMuchData))
+			// check that repeated calls to Read also return the right error
+			n, err := rb.Read([]byte{0})
+			Expect(n).To(BeZero())
+			Expect(err).To(MatchError(errTooMuchData))
+		})
+
+		It("errors if more data than the maximum length is sent, as an additional frame", func() {
+			var buf bytes.Buffer
+			buf.Write(getDataFrame([]byte("foo")))
+			buf.Write(getDataFrame([]byte("bar")))
+			str := mockquic.NewMockStream(mockCtrl)
+			str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
+			str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
+			rb := newResponseBody(&stream{Stream: str}, 3, reqDone)
+			data, err := io.ReadAll(rb)
+			Expect(err).To(MatchError(errTooMuchData))
+			Expect(data).To(Equal([]byte("foo")))
+		})
+	})
 })
diff --git a/http3/client_test.go b/http3/client_test.go
index 1155701d..5c62ec6a 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -4,18 +4,16 @@ import (
 	"bytes"
 	"compress/gzip"
 	"context"
-	"crypto/tls"
 	"errors"
-	"fmt"
 	"io"
 	"net/http"
+	"net/http/httptrace"
+	"net/textproto"
 	"sync"
 	"time"
 
 	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
-	"github.com/quic-go/quic-go/internal/protocol"
-	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 
 	"github.com/quic-go/qpack"
@@ -25,182 +23,29 @@ import (
 	"go.uber.org/mock/gomock"
 )
 
+func encodeResponse(status int) []byte {
+	buf := &bytes.Buffer{}
+	rstr := mockquic.NewMockStream(mockCtrl)
+	rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
+	rw := newResponseWriter(newStream(rstr, nil), nil, false, nil)
+	if status == http.StatusEarlyHints {
+		rw.header.Add("Link", "</style.css>; rel=preload; as=style")
+		rw.header.Add("Link", "</script.js>; rel=preload; as=script")
+	}
+	rw.WriteHeader(status)
+	rw.Flush()
+	return buf.Bytes()
+}
+
 var _ = Describe("Client", func() {
-	var (
-		cl            *client
-		req           *http.Request
-		origDialAddr  = dialAddr
-		handshakeChan <-chan struct{} // a closed chan
-	)
+	var handshakeChan <-chan struct{} // a closed chan
 
 	BeforeEach(func() {
-		origDialAddr = dialAddr
-		hostname := "quic.clemente.io:1337"
-		c, err := newClient(hostname, nil, &roundTripperOpts{MaxHeaderBytes: 1337}, nil, nil)
-		Expect(err).ToNot(HaveOccurred())
-		cl = c.(*client)
-		Expect(cl.hostname).To(Equal(hostname))
-
-		req, err = http.NewRequest("GET", "https://localhost:1337", nil)
-		Expect(err).ToNot(HaveOccurred())
-
 		ch := make(chan struct{})
 		close(ch)
 		handshakeChan = ch
 	})
 
-	AfterEach(func() {
-		dialAddr = origDialAddr
-	})
-
-	It("rejects quic.Configs that allow multiple QUIC versions", func() {
-		qconf := &quic.Config{
-			Versions: []quic.Version{protocol.Version2, protocol.Version1},
-		}
-		_, err := newClient("localhost:1337", nil, &roundTripperOpts{}, qconf, nil)
-		Expect(err).To(MatchError("can only use a single QUIC version for dialing a HTTP/3 connection"))
-	})
-
-	It("uses the default QUIC and TLS config if none is give", func() {
-		client, err := newClient("localhost:1337", nil, &roundTripperOpts{}, nil, nil)
-		Expect(err).ToNot(HaveOccurred())
-		var dialAddrCalled bool
-		dialAddr = func(_ context.Context, _ string, tlsConf *tls.Config, quicConf *quic.Config) (quic.EarlyConnection, error) {
-			Expect(quicConf.MaxIncomingStreams).To(Equal(defaultQuicConfig.MaxIncomingStreams))
-			Expect(tlsConf.NextProtos).To(Equal([]string{NextProtoH3}))
-			Expect(quicConf.Versions).To(Equal([]protocol.Version{protocol.Version1}))
-			dialAddrCalled = true
-			return nil, errors.New("test done")
-		}
-		client.RoundTripOpt(req, RoundTripOpt{})
-		Expect(dialAddrCalled).To(BeTrue())
-	})
-
-	It("adds the port to the hostname, if none is given", func() {
-		client, err := newClient("quic.clemente.io", nil, &roundTripperOpts{}, nil, nil)
-		Expect(err).ToNot(HaveOccurred())
-		var dialAddrCalled bool
-		dialAddr = func(_ context.Context, hostname string, _ *tls.Config, _ *quic.Config) (quic.EarlyConnection, error) {
-			Expect(hostname).To(Equal("quic.clemente.io:443"))
-			dialAddrCalled = true
-			return nil, errors.New("test done")
-		}
-		req, err := http.NewRequest("GET", "https://quic.clemente.io:443", nil)
-		Expect(err).ToNot(HaveOccurred())
-		client.RoundTripOpt(req, RoundTripOpt{})
-		Expect(dialAddrCalled).To(BeTrue())
-	})
-
-	It("sets the ServerName in the tls.Config, if not set", func() {
-		const host = "foo.bar"
-		dialCalled := false
-		dialFunc := func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
-			Expect(tlsCfg.ServerName).To(Equal(host))
-			dialCalled = true
-			return nil, errors.New("test done")
-		}
-		client, err := newClient(host, nil, &roundTripperOpts{}, nil, dialFunc)
-		Expect(err).ToNot(HaveOccurred())
-		req, err := http.NewRequest("GET", "https://foo.bar", nil)
-		Expect(err).ToNot(HaveOccurred())
-		client.RoundTripOpt(req, RoundTripOpt{})
-		Expect(dialCalled).To(BeTrue())
-	})
-
-	It("uses the TLS config and QUIC config", func() {
-		tlsConf := &tls.Config{
-			ServerName: "foo.bar",
-			NextProtos: []string{"proto foo", "proto bar"},
-		}
-		quicConf := &quic.Config{MaxIdleTimeout: time.Nanosecond}
-		client, err := newClient("localhost:1337", tlsConf, &roundTripperOpts{}, quicConf, nil)
-		Expect(err).ToNot(HaveOccurred())
-		var dialAddrCalled bool
-		dialAddr = func(_ context.Context, host string, tlsConfP *tls.Config, quicConfP *quic.Config) (quic.EarlyConnection, error) {
-			Expect(host).To(Equal("localhost:1337"))
-			Expect(tlsConfP.ServerName).To(Equal(tlsConf.ServerName))
-			Expect(tlsConfP.NextProtos).To(Equal([]string{NextProtoH3}))
-			Expect(quicConfP.MaxIdleTimeout).To(Equal(quicConf.MaxIdleTimeout))
-			dialAddrCalled = true
-			return nil, errors.New("test done")
-		}
-		client.RoundTripOpt(req, RoundTripOpt{})
-		Expect(dialAddrCalled).To(BeTrue())
-		// make sure the original tls.Config was not modified
-		Expect(tlsConf.NextProtos).To(Equal([]string{"proto foo", "proto bar"}))
-	})
-
-	It("uses the custom dialer, if provided", func() {
-		testErr := errors.New("test done")
-		tlsConf := &tls.Config{ServerName: "foo.bar"}
-		quicConf := &quic.Config{MaxIdleTimeout: 1337 * time.Second}
-		ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
-		defer cancel()
-		var dialerCalled bool
-		dialer := func(ctxP context.Context, address string, tlsConfP *tls.Config, quicConfP *quic.Config) (quic.EarlyConnection, error) {
-			Expect(ctxP).To(Equal(ctx))
-			Expect(address).To(Equal("localhost:1337"))
-			Expect(tlsConfP.ServerName).To(Equal("foo.bar"))
-			Expect(quicConfP.MaxIdleTimeout).To(Equal(quicConf.MaxIdleTimeout))
-			dialerCalled = true
-			return nil, testErr
-		}
-		client, err := newClient("localhost:1337", tlsConf, &roundTripperOpts{}, quicConf, dialer)
-		Expect(err).ToNot(HaveOccurred())
-		_, err = client.RoundTripOpt(req.WithContext(ctx), RoundTripOpt{})
-		Expect(err).To(MatchError(testErr))
-		Expect(dialerCalled).To(BeTrue())
-	})
-
-	It("enables HTTP/3 Datagrams", func() {
-		testErr := errors.New("handshake error")
-		client, err := newClient("localhost:1337", nil, &roundTripperOpts{EnableDatagram: true}, nil, nil)
-		Expect(err).ToNot(HaveOccurred())
-		dialAddr = func(_ context.Context, _ string, _ *tls.Config, quicConf *quic.Config) (quic.EarlyConnection, error) {
-			Expect(quicConf.EnableDatagrams).To(BeTrue())
-			return nil, testErr
-		}
-		_, err = client.RoundTripOpt(req, RoundTripOpt{})
-		Expect(err).To(MatchError(testErr))
-	})
-
-	It("errors when dialing fails", func() {
-		testErr := errors.New("handshake error")
-		client, err := newClient("localhost:1337", nil, &roundTripperOpts{}, nil, nil)
-		Expect(err).ToNot(HaveOccurred())
-		dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
-			return nil, testErr
-		}
-		_, err = client.RoundTripOpt(req, RoundTripOpt{})
-		Expect(err).To(MatchError(testErr))
-	})
-
-	It("closes correctly if connection was not created", func() {
-		client, err := newClient("localhost:1337", nil, &roundTripperOpts{}, nil, nil)
-		Expect(err).ToNot(HaveOccurred())
-		Expect(client.Close()).To(Succeed())
-	})
-
-	Context("validating the address", func() {
-		It("refuses to do requests for the wrong host", func() {
-			req, err := http.NewRequest("https", "https://quic.clemente.io:1336/foobar.html", nil)
-			Expect(err).ToNot(HaveOccurred())
-			_, err = cl.RoundTripOpt(req, RoundTripOpt{})
-			Expect(err).To(MatchError("http3 client BUG: RoundTripOpt called for the wrong client (expected quic.clemente.io:1337, got quic.clemente.io:1336)"))
-		})
-
-		It("allows requests using a different scheme", func() {
-			testErr := errors.New("handshake error")
-			req, err := http.NewRequest("masque", "masque://quic.clemente.io:1337/foobar.html", nil)
-			Expect(err).ToNot(HaveOccurred())
-			dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
-				return nil, testErr
-			}
-			_, err = cl.RoundTripOpt(req, RoundTripOpt{})
-			Expect(err).To(MatchError(testErr))
-		})
-	})
-
 	Context("hijacking bidirectional streams", func() {
 		var (
 			request              *http.Request
@@ -223,9 +68,6 @@ var _ = Describe("Client", func() {
 			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
 			conn.EXPECT().OpenStreamSync(gomock.Any()).Return(nil, errors.New("done"))
 			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(nil, errors.New("done")).AnyTimes()
-			dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
-				return conn, nil
-			}
 			var err error
 			request, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
 			Expect(err).ToNot(HaveOccurred())
@@ -237,11 +79,16 @@ var _ = Describe("Client", func() {
 		})
 
 		It("hijacks a bidirectional stream of unknown frame type", func() {
+			id := quic.ConnectionTracingID(1234)
 			frameTypeChan := make(chan FrameType, 1)
-			cl.opts.StreamHijacker = func(ft FrameType, c quic.Connection, s quic.Stream, e error) (hijacked bool, err error) {
-				Expect(e).ToNot(HaveOccurred())
-				frameTypeChan <- ft
-				return true, nil
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				StreamHijacker: func(ft FrameType, connTracingID quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
+					Expect(e).ToNot(HaveOccurred())
+					Expect(connTracingID).To(Equal(id))
+					frameTypeChan <- ft
+					return true, nil
+				},
 			}
 
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
@@ -252,7 +99,9 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
-			_, err := cl.RoundTripOpt(request, RoundTripOpt{})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
+			_, err := rt.RoundTrip(request)
 			Expect(err).To(MatchError("done"))
 			Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
 			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -260,10 +109,13 @@ var _ = Describe("Client", func() {
 
 		It("closes the connection when hijacker didn't hijack a bidirectional stream", func() {
 			frameTypeChan := make(chan FrameType, 1)
-			cl.opts.StreamHijacker = func(ft FrameType, c quic.Connection, s quic.Stream, e error) (hijacked bool, err error) {
-				Expect(e).ToNot(HaveOccurred())
-				frameTypeChan <- ft
-				return false, nil
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				StreamHijacker: func(ft FrameType, _ quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
+					Expect(e).ToNot(HaveOccurred())
+					frameTypeChan <- ft
+					return false, nil
+				},
 			}
 
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
@@ -274,18 +126,23 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
 			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).Return(nil).AnyTimes()
-			_, err := cl.RoundTripOpt(request, RoundTripOpt{})
+			_, err := rt.RoundTrip(request)
 			Expect(err).To(MatchError("done"))
 			Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
 		})
 
 		It("closes the connection when hijacker returned error", func() {
 			frameTypeChan := make(chan FrameType, 1)
-			cl.opts.StreamHijacker = func(ft FrameType, c quic.Connection, s quic.Stream, e error) (hijacked bool, err error) {
-				Expect(e).ToNot(HaveOccurred())
-				frameTypeChan <- ft
-				return false, errors.New("error in hijacker")
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				StreamHijacker: func(ft FrameType, _ quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
+					Expect(e).ToNot(HaveOccurred())
+					frameTypeChan <- ft
+					return false, errors.New("error in hijacker")
+				},
 			}
 
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
@@ -296,8 +153,10 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
 			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).Return(nil).AnyTimes()
-			_, err := cl.RoundTripOpt(request, RoundTripOpt{})
+			_, err := rt.RoundTrip(request)
 			Expect(err).To(MatchError("done"))
 			Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
 		})
@@ -306,12 +165,15 @@ var _ = Describe("Client", func() {
 			testErr := errors.New("test error")
 			unknownStr := mockquic.NewMockStream(mockCtrl)
 			done := make(chan struct{})
-			cl.opts.StreamHijacker = func(ft FrameType, c quic.Connection, str quic.Stream, e error) (hijacked bool, err error) {
-				defer close(done)
-				Expect(e).To(MatchError(testErr))
-				Expect(ft).To(BeZero())
-				Expect(str).To(Equal(unknownStr))
-				return false, nil
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				StreamHijacker: func(ft FrameType, _ quic.ConnectionTracingID, str quic.Stream, e error) (hijacked bool, err error) {
+					defer close(done)
+					Expect(e).To(MatchError(testErr))
+					Expect(ft).To(BeZero())
+					Expect(str).To(Equal(unknownStr))
+					return false, nil
+				},
 			}
 
 			unknownStr.EXPECT().Read(gomock.Any()).Return(0, testErr).AnyTimes()
@@ -320,8 +182,10 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
 			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), gomock.Any()).Return(nil).AnyTimes()
-			_, err := cl.RoundTripOpt(request, RoundTripOpt{})
+			_, err := rt.RoundTrip(request)
 			Expect(err).To(MatchError("done"))
 			Eventually(done).Should(BeClosed())
 			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -349,9 +213,6 @@ var _ = Describe("Client", func() {
 			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
 			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
 			conn.EXPECT().OpenStreamSync(gomock.Any()).Return(nil, errors.New("done"))
-			dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
-				return conn, nil
-			}
 			var err error
 			req, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
 			Expect(err).ToNot(HaveOccurred())
@@ -363,11 +224,16 @@ var _ = Describe("Client", func() {
 		})
 
 		It("hijacks an unidirectional stream of unknown stream type", func() {
+			id := quic.ConnectionTracingID(100)
 			streamTypeChan := make(chan StreamType, 1)
-			cl.opts.UniStreamHijacker = func(st StreamType, _ quic.Connection, _ quic.ReceiveStream, err error) bool {
-				Expect(err).ToNot(HaveOccurred())
-				streamTypeChan <- st
-				return true
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				UniStreamHijacker: func(st StreamType, connTracingID quic.ConnectionTracingID, _ quic.ReceiveStream, err error) bool {
+					Expect(connTracingID).To(Equal(id))
+					Expect(err).ToNot(HaveOccurred())
+					streamTypeChan <- st
+					return true
+				},
 			}
 
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x54))
@@ -380,7 +246,9 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
-			_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
+			_, err := rt.RoundTrip(req)
 			Expect(err).To(MatchError("done"))
 			Eventually(streamTypeChan).Should(Receive(BeEquivalentTo(0x54)))
 			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -390,12 +258,15 @@ var _ = Describe("Client", func() {
 			testErr := errors.New("test error")
 			done := make(chan struct{})
 			unknownStr := mockquic.NewMockStream(mockCtrl)
-			cl.opts.UniStreamHijacker = func(st StreamType, _ quic.Connection, str quic.ReceiveStream, err error) bool {
-				defer close(done)
-				Expect(st).To(BeZero())
-				Expect(str).To(Equal(unknownStr))
-				Expect(err).To(MatchError(testErr))
-				return true
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				UniStreamHijacker: func(st StreamType, _ quic.ConnectionTracingID, str quic.ReceiveStream, err error) bool {
+					defer close(done)
+					Expect(st).To(BeZero())
+					Expect(str).To(Equal(unknownStr))
+					Expect(err).To(MatchError(testErr))
+					return true
+				},
 			}
 
 			unknownStr.EXPECT().Read(gomock.Any()).Return(0, testErr)
@@ -404,7 +275,9 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
-			_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
+			_, err := rt.RoundTrip(req)
 			Expect(err).To(MatchError("done"))
 			Eventually(done).Should(BeClosed())
 			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -412,10 +285,13 @@ var _ = Describe("Client", func() {
 
 		It("cancels reading when hijacker didn't hijack an unidirectional stream", func() {
 			streamTypeChan := make(chan StreamType, 1)
-			cl.opts.UniStreamHijacker = func(st StreamType, _ quic.Connection, _ quic.ReceiveStream, err error) bool {
-				Expect(err).ToNot(HaveOccurred())
-				streamTypeChan <- st
-				return false
+			rt := &SingleDestinationRoundTripper{
+				Connection: conn,
+				UniStreamHijacker: func(st StreamType, _ quic.ConnectionTracingID, _ quic.ReceiveStream, err error) bool {
+					Expect(err).ToNot(HaveOccurred())
+					streamTypeChan <- st
+					return false
+				},
 			}
 
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x54))
@@ -429,7 +305,9 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
-			_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+			ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+			conn.EXPECT().Context().Return(ctx).AnyTimes()
+			_, err := rt.RoundTrip(req)
 			Expect(err).To(MatchError("done"))
 			Eventually(streamTypeChan).Should(Receive(BeEquivalentTo(0x54)))
 			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -437,58 +315,138 @@ var _ = Describe("Client", func() {
 	})
 
 	Context("SETTINGS handling", func() {
-		var (
-			req                  *http.Request
-			conn                 *mockquic.MockEarlyConnection
-			settingsFrameWritten chan struct{}
-		)
-		testDone := make(chan struct{}, 1)
+		var settingsFrameWritten chan struct{}
 
 		BeforeEach(func() {
 			settingsFrameWritten = make(chan struct{})
 			controlStr := mockquic.NewMockStream(mockCtrl)
+			var buf bytes.Buffer
 			controlStr.EXPECT().Write(gomock.Any()).Do(func(b []byte) (int, error) {
 				defer GinkgoRecover()
+				buf.Write(b)
 				close(settingsFrameWritten)
 				return len(b), nil
 			})
-			conn = mockquic.NewMockEarlyConnection(mockCtrl)
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
 			conn.EXPECT().OpenUniStream().Return(controlStr, nil)
+			conn.EXPECT().OpenStreamSync(gomock.Any()).DoAndReturn(func(context.Context) (quic.Stream, error) {
+				<-settingsFrameWritten
+				return nil, errors.New("test done")
+			})
+			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+				<-settingsFrameWritten
+				return nil, errors.New("test done")
+			}).AnyTimes()
 			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
-				return conn, nil
+			rt := &SingleDestinationRoundTripper{
+				Connection:      conn,
+				EnableDatagrams: true,
 			}
-			var err error
-			req, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
+			req, err := http.NewRequest(http.MethodGet, "https://quic-go.net", nil)
 			Expect(err).ToNot(HaveOccurred())
+			_, err = rt.RoundTrip(req)
+			Expect(err).To(MatchError("test done"))
+			t, err := quicvarint.Read(&buf)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(t).To(BeEquivalentTo(streamTypeControlStream))
+			settings, err := parseSettingsFrame(&buf, uint64(buf.Len()))
+			Expect(err).ToNot(HaveOccurred())
+			Expect(settings.Datagram).To(BeTrue())
 		})
 
-		AfterEach(func() {
-			testDone <- struct{}{}
-			Eventually(settingsFrameWritten).Should(BeClosed())
+		It("receives SETTINGS", func() {
+			done := make(chan struct{})
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
+				<-done
+				return nil, errors.New("test done")
+			}).MaxTimes(1)
+			b := quicvarint.Append(nil, streamTypeControlStream)
+			b = (&settingsFrame{Datagram: true}).Append(b)
+			r := bytes.NewReader(b)
+			controlStr := mockquic.NewMockStream(mockCtrl)
+			controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
+			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
+			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
+				<-done
+				return nil, errors.New("test done")
+			})
+
+			rt := &SingleDestinationRoundTripper{Connection: conn}
+			hconn := rt.Start()
+			Eventually(hconn.ReceivedSettings()).Should(BeClosed())
+			settings := hconn.Settings()
+			Expect(settings.EnableDatagram).To(BeTrue())
+			// test shutdown
+			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
+			close(done)
 		})
 
-		It("allows the client to reject the SETTINGS using the CheckSettings RoundTripOpt", func() {
+		It("checks the server's SETTINGS before sending an Extended CONNECT request", func() {
+			done := make(chan struct{})
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
+				<-done
+				return nil, errors.New("test done")
+			}).MaxTimes(1)
 			b := quicvarint.Append(nil, streamTypeControlStream)
-			b = (&settingsFrame{}).Append(b)
+			b = (&settingsFrame{ExtendedConnect: true}).Append(b)
 			r := bytes.NewReader(b)
 			controlStr := mockquic.NewMockStream(mockCtrl)
 			controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-			// Don't EXPECT any call to OpenStreamSync.
-			// When the SETTINGS are rejected, we don't even open the request stream.
+			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
 			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				return controlStr, nil
+				<-done
+				return nil, errors.New("test done")
 			})
+			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
+			conn.EXPECT().Context().Return(context.Background())
+			conn.EXPECT().OpenStreamSync(gomock.Any()).Return(nil, errors.New("test error"))
+
+			rt := &SingleDestinationRoundTripper{Connection: conn}
+			_, err := rt.RoundTrip(&http.Request{
+				Method: http.MethodConnect,
+				Proto:  "connect",
+				Host:   "localhost",
+			})
+			Expect(err).To(MatchError("test error"))
+
+			// test shutdown
+			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
+			close(done)
+		})
+
+		It("rejects Extended CONNECT requests if the server doesn't enable it", func() {
+			done := make(chan struct{})
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().OpenUniStream().DoAndReturn(func() (quic.SendStream, error) {
+				<-done
+				return nil, errors.New("test done")
+			}).MaxTimes(1)
+			b := quicvarint.Append(nil, streamTypeControlStream)
+			b = (&settingsFrame{Datagram: true}).Append(b)
+			r := bytes.NewReader(b)
+			controlStr := mockquic.NewMockStream(mockCtrl)
+			controlStr.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
+			conn.EXPECT().AcceptUniStream(gomock.Any()).Return(controlStr, nil)
 			conn.EXPECT().AcceptUniStream(gomock.Any()).DoAndReturn(func(context.Context) (quic.ReceiveStream, error) {
-				<-testDone
+				<-done
 				return nil, errors.New("test done")
 			})
+			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
 			conn.EXPECT().Context().Return(context.Background())
-			_, err := cl.RoundTripOpt(req, RoundTripOpt{CheckSettings: func(settings Settings) error {
-				return errors.New("wrong settings")
-			}})
-			Expect(err).To(MatchError("wrong settings"))
-			time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
+
+			rt := &SingleDestinationRoundTripper{Connection: conn}
+			_, err := rt.RoundTrip(&http.Request{
+				Method: http.MethodConnect,
+				Proto:  "connect",
+				Host:   "localhost",
+			})
+			Expect(err).To(MatchError("http3: server didn't enable Extended CONNECT"))
+
+			// test shutdown
+			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
+			close(done)
 		})
 	})
 
@@ -497,6 +455,7 @@ var _ = Describe("Client", func() {
 			req                  *http.Request
 			str                  *mockquic.MockStream
 			conn                 *mockquic.MockEarlyConnection
+			cl                   *SingleDestinationRoundTripper
 			settingsFrameWritten chan struct{}
 		)
 		testDone := make(chan struct{})
@@ -520,16 +479,6 @@ var _ = Describe("Client", func() {
 			return fields
 		}
 
-		getResponse := func(status int) []byte {
-			buf := &bytes.Buffer{}
-			rstr := mockquic.NewMockStream(mockCtrl)
-			rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-			rw := newResponseWriter(rstr, nil, utils.DefaultLogger)
-			rw.WriteHeader(status)
-			rw.Flush()
-			return buf.Bytes()
-		}
-
 		BeforeEach(func() {
 			settingsFrameWritten = make(chan struct{})
 			controlStr := mockquic.NewMockStream(mockCtrl)
@@ -549,9 +498,7 @@ var _ = Describe("Client", func() {
 				<-testDone
 				return nil, errors.New("test done")
 			})
-			dialAddr = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
-				return conn, nil
-			}
+			cl = &SingleDestinationRoundTripper{Connection: conn}
 			var err error
 			req, err = http.NewRequest("GET", "https://quic.clemente.io:1337/file1.dat", nil)
 			Expect(err).ToNot(HaveOccurred())
@@ -562,12 +509,12 @@ var _ = Describe("Client", func() {
 			Eventually(settingsFrameWritten).Should(BeClosed())
 		})
 
-		It("errors if it can't open a stream", func() {
+		It("errors if it can't open a request stream", func() {
 			testErr := errors.New("stream open error")
 			conn.EXPECT().OpenStreamSync(context.Background()).Return(nil, testErr)
 			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).MaxTimes(1)
 			conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-			_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+			_, err := cl.RoundTrip(req)
 			Expect(err).To(MatchError(testErr))
 		})
 
@@ -582,10 +529,11 @@ var _ = Describe("Client", func() {
 				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
 				str.EXPECT().Close()
 				str.EXPECT().CancelWrite(gomock.Any())
+				str.EXPECT().CancelRead(gomock.Any())
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
 					return 0, testErr
 				})
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+				_, err := cl.RoundTrip(req)
 				Expect(err).To(MatchError(testErr))
 				Expect(decodeHeader(buf)).To(HaveKeyWithValue(":method", serialized))
 				// make sure the request wasn't modified
@@ -596,7 +544,7 @@ var _ = Describe("Client", func() {
 		)
 
 		It("returns a response", func() {
-			rspBuf := bytes.NewBuffer(getResponse(418))
+			rspBuf := bytes.NewBuffer(encodeResponse(418))
 			gomock.InOrder(
 				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
 				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
@@ -605,7 +553,7 @@ var _ = Describe("Client", func() {
 			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
 			str.EXPECT().Close()
 			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			rsp, err := cl.RoundTripOpt(req, RoundTripOpt{})
+			rsp, err := cl.RoundTrip(req)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
 			Expect(rsp.ProtoMajor).To(Equal(3))
@@ -613,22 +561,6 @@ var _ = Describe("Client", func() {
 			Expect(rsp.Request).ToNot(BeNil())
 		})
 
-		It("doesn't close the request stream, with DontCloseRequestStream set", func() {
-			rspBuf := bytes.NewBuffer(getResponse(418))
-			gomock.InOrder(
-				conn.EXPECT().HandshakeComplete().Return(handshakeChan),
-				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil),
-				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
-			)
-			str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
-			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
-			rsp, err := cl.RoundTripOpt(req, RoundTripOpt{DontCloseRequestStream: true})
-			Expect(err).ToNot(HaveOccurred())
-			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
-			Expect(rsp.ProtoMajor).To(Equal(3))
-			Expect(rsp.StatusCode).To(Equal(418))
-		})
-
 		Context("requests containing a Body", func() {
 			var strBuf *bytes.Buffer
 
@@ -650,15 +582,18 @@ var _ = Describe("Client", func() {
 				done := make(chan struct{})
 				gomock.InOrder(
 					str.EXPECT().Close().Do(func() error { close(done); return nil }),
-					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1), // when reading the response errors
+					// when reading the response errors
+					str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1),
+					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1),
 				)
 				// the response body is sent asynchronously, while already reading the response
+				testErr := errors.New("test done")
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
 					<-done
-					return 0, errors.New("test done")
+					return 0, testErr
 				})
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("test done"))
+				_, err := cl.RoundTrip(req)
+				Expect(err).To(MatchError(testErr))
 				hfs := decodeHeader(strBuf)
 				Expect(hfs).To(HaveKeyWithValue(":method", "POST"))
 				Expect(hfs).To(HaveKeyWithValue(":path", "/upload"))
@@ -668,6 +603,7 @@ var _ = Describe("Client", func() {
 				req.ContentLength = 7
 				var once sync.Once
 				done := make(chan struct{})
+				str.EXPECT().CancelRead(gomock.Any())
 				gomock.InOrder(
 					str.EXPECT().CancelWrite(gomock.Any()).Do(func(c quic.StreamErrorCode) {
 						once.Do(func() {
@@ -682,7 +618,7 @@ var _ = Describe("Client", func() {
 					<-done
 					return 0, errors.New("done")
 				})
-				cl.RoundTripOpt(req, RoundTripOpt{})
+				cl.RoundTrip(req)
 				Expect(strBuf.String()).To(ContainSubstring("request"))
 				Expect(strBuf.String()).ToNot(ContainSubstring("request body"))
 			})
@@ -690,6 +626,7 @@ var _ = Describe("Client", func() {
 			It("returns the error that occurred when reading the body", func() {
 				req.Body.(*mockBody).readErr = errors.New("testErr")
 				done := make(chan struct{})
+				str.EXPECT().CancelRead(gomock.Any())
 				gomock.InOrder(
 					str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) {
 						close(done)
@@ -698,14 +635,15 @@ var _ = Describe("Client", func() {
 				)
 
 				// the response body is sent asynchronously, while already reading the response
+				testErr := errors.New("test done")
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
 					<-done
-					return 0, errors.New("test done")
+					return 0, testErr
 				})
 				closed := make(chan struct{})
 				str.EXPECT().Close().Do(func() error { close(closed); return nil })
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("test done"))
+				_, err := cl.RoundTrip(req)
+				Expect(err).To(MatchError(testErr))
 				Eventually(closed).Should(BeClosed())
 			})
 
@@ -716,8 +654,8 @@ var _ = Describe("Client", func() {
 				r := bytes.NewReader(b)
 				str.EXPECT().Close().Do(func() error { close(closed); return nil })
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("expected first frame to be a HEADERS frame"))
+				_, err := cl.RoundTrip(req)
+				Expect(err).To(MatchError("http3: expected first frame to be a HEADERS frame"))
 				Eventually(closed).Should(BeClosed())
 			})
 
@@ -730,81 +668,89 @@ var _ = Describe("Client", func() {
 				b = append(b, headerBuf.Bytes()...)
 
 				r := bytes.NewReader(b)
+				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
 				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
 				closed := make(chan struct{})
 				str.EXPECT().Close().Do(func() error { close(closed); return nil })
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+				_, err := cl.RoundTrip(req)
 				Expect(err).To(HaveOccurred())
 				Eventually(closed).Should(BeClosed())
 			})
 
 			It("cancels the stream when the HEADERS frame is too large", func() {
+				cl.MaxResponseHeaderBytes = 1337
 				b := (&headersFrame{Length: 1338}).Append(nil)
 				r := bytes.NewReader(b)
+				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
 				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeFrameError))
 				closed := make(chan struct{})
 				str.EXPECT().Close().Do(func() error { close(closed); return nil })
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(r.Read).AnyTimes()
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("HEADERS frame too large: 1338 bytes (max: 1337)"))
+				_, err := cl.RoundTrip(req)
+				Expect(err).To(MatchError("http3: HEADERS frame too large: 1338 bytes (max: 1337)"))
 				Eventually(closed).Should(BeClosed())
 			})
+
+			It("opens a request stream", func() {
+				cl.Connection.(quic.EarlyConnection).HandshakeComplete()
+				str, err := cl.OpenRequestStream(context.Background())
+				Expect(err).ToNot(HaveOccurred())
+				Expect(str.SendRequestHeader(req)).To(Succeed())
+				str.Write([]byte("foobar"))
+				d := dataFrame{Length: 6}
+				data := d.Append([]byte{})
+				data = append(data, []byte("foobar")...)
+				Expect(bytes.Contains(strBuf.Bytes(), data)).To(BeTrue())
+			})
 		})
 
 		Context("request cancellations", func() {
-			for _, dontClose := range []bool{false, true} {
-				dontClose := dontClose
-
-				Context(fmt.Sprintf("with DontCloseRequestStream: %t", dontClose), func() {
-					roundTripOpt := RoundTripOpt{DontCloseRequestStream: dontClose}
-
-					It("cancels a request while waiting for the handshake to complete", func() {
-						ctx, cancel := context.WithCancel(context.Background())
-						req := req.WithContext(ctx)
-						conn.EXPECT().HandshakeComplete().Return(make(chan struct{}))
-
-						errChan := make(chan error)
-						go func() {
-							_, err := cl.RoundTripOpt(req, roundTripOpt)
-							errChan <- err
-						}()
-						Consistently(errChan).ShouldNot(Receive())
-						cancel()
-						Eventually(errChan).Should(Receive(MatchError("context canceled")))
-					})
-
-					It("cancels a request while the request is still in flight", func() {
-						ctx, cancel := context.WithCancel(context.Background())
-						req := req.WithContext(ctx)
-						conn.EXPECT().HandshakeComplete().Return(handshakeChan)
-						conn.EXPECT().OpenStreamSync(ctx).Return(str, nil)
-						buf := &bytes.Buffer{}
-						str.EXPECT().Close().MaxTimes(1)
-
-						str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
-
-						done := make(chan struct{})
-						canceled := make(chan struct{})
-						gomock.InOrder(
-							str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(canceled) }),
-							str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(done) }),
-						)
-						str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1)
-						str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
-							cancel()
-							<-canceled
-							return 0, errors.New("test done")
-						})
-						_, err := cl.RoundTripOpt(req, roundTripOpt)
-						Expect(err).To(MatchError(context.Canceled))
-						Eventually(done).Should(BeClosed())
-					})
+			It("cancels a request while waiting for the handshake to complete", func() {
+				ctx, cancel := context.WithCancel(context.Background())
+				req := req.WithContext(ctx)
+				conn.EXPECT().HandshakeComplete().Return(make(chan struct{}))
+
+				errChan := make(chan error)
+				go func() {
+					_, err := cl.RoundTrip(req)
+					errChan <- err
+				}()
+				Consistently(errChan).ShouldNot(Receive())
+				cancel()
+				Eventually(errChan).Should(Receive(MatchError("context canceled")))
+			})
+
+			It("cancels a request while the request is still in flight", func() {
+				ctx, cancel := context.WithCancel(context.Background())
+				req := req.WithContext(ctx)
+				conn.EXPECT().HandshakeComplete().Return(handshakeChan)
+				conn.EXPECT().OpenStreamSync(ctx).Return(str, nil)
+				buf := &bytes.Buffer{}
+				str.EXPECT().Close().MaxTimes(1)
+
+				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
+
+				done := make(chan struct{})
+				canceled := make(chan struct{})
+				gomock.InOrder(
+					str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(canceled) }),
+					str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(done) }),
+				)
+				str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1)
+				str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1)
+				str.EXPECT().Read(gomock.Any()).DoAndReturn(func([]byte) (int, error) {
+					cancel()
+					<-canceled
+					return 0, errors.New("test done")
 				})
-			}
+				_, err := cl.RoundTrip(req)
+				Expect(err).To(MatchError(context.Canceled))
+				Eventually(done).Should(BeClosed())
+			})
 
 			It("cancels a request after the response arrived", func() {
-				rspBuf := bytes.NewBuffer(getResponse(404))
+				rspBuf := bytes.NewBuffer(encodeResponse(404))
 
 				ctx, cancel := context.WithCancel(context.Background())
 				req := req.WithContext(ctx)
@@ -819,7 +765,7 @@ var _ = Describe("Client", func() {
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
 				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestCanceled))
 				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestCanceled)).Do(func(quic.StreamErrorCode) { close(done) })
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
+				_, err := cl.RoundTrip(req)
 				Expect(err).ToNot(HaveOccurred())
 				cancel()
 				Eventually(done).Should(BeClosed())
@@ -837,28 +783,36 @@ var _ = Describe("Client", func() {
 				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
 				gomock.InOrder(
 					str.EXPECT().Close(),
-					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1), // when the Read errors
+					// when the Read errors
+					str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1),
+					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1),
 				)
-				str.EXPECT().Read(gomock.Any()).Return(0, errors.New("test done"))
-				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("test done"))
+				testErr := errors.New("test done")
+				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
+				_, err := cl.RoundTrip(req)
+				Expect(err).To(MatchError(testErr))
 				hfs := decodeHeader(buf)
 				Expect(hfs).To(HaveKeyWithValue("accept-encoding", "gzip"))
 			})
 
 			It("doesn't add gzip if the header disable it", func() {
-				client, err := newClient("quic.clemente.io:1337", nil, &roundTripperOpts{DisableCompression: true}, nil, nil)
-				Expect(err).ToNot(HaveOccurred())
+				client := &SingleDestinationRoundTripper{
+					Connection:         conn,
+					DisableCompression: true,
+				}
 				conn.EXPECT().OpenStreamSync(context.Background()).Return(str, nil)
 				buf := &bytes.Buffer{}
 				str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write)
 				gomock.InOrder(
 					str.EXPECT().Close(),
-					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1), // when the Read errors
+					// when the Read errors
+					str.EXPECT().CancelRead(gomock.Any()).MaxTimes(1),
+					str.EXPECT().CancelWrite(gomock.Any()).MaxTimes(1),
 				)
-				str.EXPECT().Read(gomock.Any()).Return(0, errors.New("test done"))
-				_, err = client.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("test done"))
+				testErr := errors.New("test done")
+				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
+				_, err := client.RoundTrip(req)
+				Expect(err).To(MatchError(testErr))
 				hfs := decodeHeader(buf)
 				Expect(hfs).ToNot(HaveKey("accept-encoding"))
 			})
@@ -869,7 +823,7 @@ var _ = Describe("Client", func() {
 				buf := &bytes.Buffer{}
 				rstr := mockquic.NewMockStream(mockCtrl)
 				rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-				rw := newResponseWriter(rstr, nil, utils.DefaultLogger)
+				rw := newResponseWriter(newStream(rstr, nil), nil, false, nil)
 				rw.Header().Set("Content-Encoding", "gzip")
 				gz := gzip.NewWriter(rw)
 				gz.Write([]byte("gzipped response"))
@@ -879,7 +833,7 @@ var _ = Describe("Client", func() {
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
 				str.EXPECT().Close()
 
-				rsp, err := cl.RoundTripOpt(req, RoundTripOpt{})
+				rsp, err := cl.RoundTrip(req)
 				Expect(err).ToNot(HaveOccurred())
 				data, err := io.ReadAll(rsp.Body)
 				Expect(err).ToNot(HaveOccurred())
@@ -895,14 +849,14 @@ var _ = Describe("Client", func() {
 				buf := &bytes.Buffer{}
 				rstr := mockquic.NewMockStream(mockCtrl)
 				rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-				rw := newResponseWriter(rstr, nil, utils.DefaultLogger)
+				rw := newResponseWriter(newStream(rstr, nil), nil, false, nil)
 				rw.Write([]byte("not gzipped"))
 				rw.Flush()
 				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
 				str.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
 				str.EXPECT().Close()
 
-				rsp, err := cl.RoundTripOpt(req, RoundTripOpt{})
+				rsp, err := cl.RoundTrip(req)
 				Expect(err).ToNot(HaveOccurred())
 				data, err := io.ReadAll(rsp.Body)
 				Expect(err).ToNot(HaveOccurred())
@@ -910,5 +864,75 @@ var _ = Describe("Client", func() {
 				Expect(rsp.Header.Get("Content-Encoding")).To(BeEmpty())
 			})
 		})
+
+		Context("1xx status code", func() {
+			It("continues to read next header if code is 103", func() {
+				var (
+					cnt    int
+					status int
+					hdr    textproto.MIMEHeader
+				)
+				header1 := "</style.css>; rel=preload; as=style"
+				header2 := "</script.js>; rel=preload; as=script"
+				ctx := httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
+					Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+						cnt++
+						status = code
+						hdr = header
+						return nil
+					},
+				})
+				req := req.WithContext(ctx)
+				rspBuf := bytes.NewBuffer(encodeResponse(103))
+				gomock.InOrder(
+					conn.EXPECT().HandshakeComplete().Return(handshakeChan),
+					conn.EXPECT().OpenStreamSync(ctx).Return(str, nil),
+					conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
+				)
+				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
+				str.EXPECT().Close()
+				str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
+				rsp, err := cl.RoundTrip(req)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(rsp.Proto).To(Equal("HTTP/3.0"))
+				Expect(rsp.ProtoMajor).To(Equal(3))
+				Expect(rsp.StatusCode).To(Equal(200))
+				Expect(rsp.Header).To(HaveKeyWithValue("Link", []string{header1, header2}))
+				Expect(status).To(Equal(103))
+				Expect(cnt).To(Equal(1))
+				Expect(hdr).To(HaveKeyWithValue("Link", []string{header1, header2}))
+				Expect(rsp.Request).ToNot(BeNil())
+			})
+
+			It("doesn't continue to read next header if code is a terminal status", func() {
+				cnt := 0
+				status := 0
+				ctx := httptrace.WithClientTrace(req.Context(), &httptrace.ClientTrace{
+					Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+						cnt++
+						status = code
+						return nil
+					},
+				})
+				req := req.WithContext(ctx)
+				rspBuf := bytes.NewBuffer(encodeResponse(101))
+				gomock.InOrder(
+					conn.EXPECT().HandshakeComplete().Return(handshakeChan),
+					conn.EXPECT().OpenStreamSync(ctx).Return(str, nil),
+					conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}),
+				)
+				str.EXPECT().Write(gomock.Any()).AnyTimes().DoAndReturn(func(p []byte) (int, error) { return len(p), nil })
+				str.EXPECT().Close()
+				str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
+				rsp, err := cl.RoundTrip(req)
+				Expect(err).ToNot(HaveOccurred())
+				Expect(rsp.Proto).To(Equal("HTTP/3.0"))
+				Expect(rsp.ProtoMajor).To(Equal(3))
+				Expect(rsp.StatusCode).To(Equal(101))
+				Expect(status).To(Equal(0))
+				Expect(cnt).To(Equal(0))
+				Expect(rsp.Request).ToNot(BeNil())
+			})
+		})
 	})
 })
diff --git a/http3/conn_test.go b/http3/conn_test.go
index 643236cb..45c66170 100644
--- a/http3/conn_test.go
+++ b/http3/conn_test.go
@@ -11,7 +11,6 @@ import (
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
-	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 
 	. "github.com/onsi/ginkgo/v2"
@@ -28,7 +27,7 @@ var _ = Describe("Connection", func() {
 				false,
 				nil,
 				protocol.PerspectiveServer,
-				utils.DefaultLogger,
+				nil,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{
@@ -61,7 +60,7 @@ var _ = Describe("Connection", func() {
 				false,
 				nil,
 				protocol.PerspectiveServer,
-				utils.DefaultLogger,
+				nil,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{}).Append(b)
@@ -103,7 +102,7 @@ var _ = Describe("Connection", func() {
 					false,
 					nil,
 					protocol.PerspectiveClient,
-					utils.DefaultLogger,
+					nil,
 				)
 				buf := bytes.NewBuffer(quicvarint.Append(nil, streamType))
 				str := mockquic.NewMockStream(mockCtrl)
@@ -132,7 +131,7 @@ var _ = Describe("Connection", func() {
 					false,
 					nil,
 					protocol.PerspectiveClient,
-					utils.DefaultLogger,
+					nil,
 				)
 				buf := bytes.NewBuffer(quicvarint.Append(nil, streamType))
 				str1 := mockquic.NewMockStream(mockCtrl)
@@ -168,7 +167,7 @@ var _ = Describe("Connection", func() {
 				false,
 				nil,
 				protocol.PerspectiveServer,
-				utils.DefaultLogger,
+				nil,
 			)
 			buf := bytes.NewBuffer(quicvarint.Append(nil, 0x1337))
 			str := mockquic.NewMockStream(mockCtrl)
@@ -194,7 +193,7 @@ var _ = Describe("Connection", func() {
 				false,
 				nil,
 				protocol.PerspectiveServer,
-				utils.DefaultLogger,
+				nil,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&dataFrame{}).Append(b)
@@ -225,7 +224,7 @@ var _ = Describe("Connection", func() {
 				false,
 				nil,
 				protocol.PerspectiveServer,
-				utils.DefaultLogger,
+				nil,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{}).Append(b)
@@ -263,7 +262,7 @@ var _ = Describe("Connection", func() {
 					false,
 					nil,
 					pers.Opposite(),
-					utils.DefaultLogger,
+					nil,
 				)
 				buf := bytes.NewBuffer(quicvarint.Append(nil, streamTypePushStream))
 				controlStr := mockquic.NewMockStream(mockCtrl)
@@ -293,7 +292,7 @@ var _ = Describe("Connection", func() {
 				true,
 				nil,
 				protocol.PerspectiveClient,
-				utils.DefaultLogger,
+				nil,
 			)
 			b := quicvarint.Append(nil, streamTypeControlStream)
 			b = (&settingsFrame{Datagram: true}).Append(b)
diff --git a/http3/headers_test.go b/http3/headers_test.go
index 1fb6ccc5..89c76e5b 100644
--- a/http3/headers_test.go
+++ b/http3/headers_test.go
@@ -293,21 +293,12 @@ var _ = Describe("Request", func() {
 			Expect(err).ToNot(HaveOccurred())
 		})
 
-		It("uses req.URL.Host", func() {
-			req := &http.Request{URL: url}
-			Expect(hostnameFromRequest(req)).To(Equal("quic.clemente.io:1337"))
-		})
-
-		It("uses req.URL.Host even if req.Host is available", func() {
-			req := &http.Request{
-				Host: "www.example.org",
-				URL:  url,
-			}
-			Expect(hostnameFromRequest(req)).To(Equal("quic.clemente.io:1337"))
+		It("uses URL.Host", func() {
+			Expect(hostnameFromURL(url)).To(Equal("quic.clemente.io:1337"))
 		})
 
 		It("returns an empty hostname if nothing is set", func() {
-			Expect(hostnameFromRequest(&http.Request{})).To(BeEmpty())
+			Expect(hostnameFromURL(nil)).To(BeEmpty())
 		})
 	})
 })
diff --git a/http3/http_stream_test.go b/http3/http_stream_test.go
index 2d2dc3f3..6e989ec3 100644
--- a/http3/http_stream_test.go
+++ b/http3/http_stream_test.go
@@ -3,9 +3,13 @@ package http3
 import (
 	"bytes"
 	"io"
+	"math"
+	"net/http"
 
-	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
+	"github.com/quic-go/quic-go/internal/qerr"
+
+	"github.com/quic-go/qpack"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -26,15 +30,18 @@ var _ = Describe("Stream", func() {
 			errorCbCalled bool
 		)
 
-		errorCb := func() { errorCbCalled = true }
-
 		BeforeEach(func() {
 			buf = &bytes.Buffer{}
 			errorCbCalled = false
 			qstr = mockquic.NewMockStream(mockCtrl)
 			qstr.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
 			qstr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-			str = newStream(qstr, errorCb)
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().CloseWithError(gomock.Any(), gomock.Any()).Do(func(qerr.ApplicationErrorCode, string) error {
+				errorCbCalled = true
+				return nil
+			}).AnyTimes()
+			str = newStream(qstr, conn)
 		})
 
 		It("reads DATA frames in a single run", func() {
@@ -151,53 +158,54 @@ var _ = Describe("Stream", func() {
 	})
 })
 
-var _ = Describe("length-limited streams", func() {
-	var (
-		str  *stream
-		qstr *mockquic.MockStream
-		buf  *bytes.Buffer
-	)
+var _ = Describe("Request Stream", func() {
+	var str *requestStream
+	var qstr *mockquic.MockStream
 
 	BeforeEach(func() {
-		buf = &bytes.Buffer{}
 		qstr = mockquic.NewMockStream(mockCtrl)
-		qstr.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
-		qstr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
-		str = newStream(qstr, func() { Fail("didn't expect error callback to be called") })
+		requestWriter := newRequestWriter()
+		conn := mockquic.NewMockEarlyConnection(mockCtrl)
+		str = newRequestStream(
+			newStream(qstr, conn),
+			requestWriter,
+			make(chan struct{}),
+			qpack.NewDecoder(func(qpack.HeaderField) {}),
+			true,
+			math.MaxUint64,
+		)
 	})
 
-	It("reads all frames", func() {
-		s := newLengthLimitedStream(str, 6)
-		buf.Write(getDataFrame([]byte("foo")))
-		buf.Write(getDataFrame([]byte("bar")))
-		data, err := io.ReadAll(s)
-		Expect(err).ToNot(HaveOccurred())
-		Expect(data).To(Equal([]byte("foobar")))
+	It("refuses to read before having read the response", func() {
+		_, err := str.Read(make([]byte, 100))
+		Expect(err).To(MatchError("http3: invalid use of RequestStream.Read: need to call ReadResponse first"))
 	})
 
-	It("errors if more data than the maximum length is sent, in the middle of a frame", func() {
-		s := newLengthLimitedStream(str, 4)
-		buf.Write(getDataFrame([]byte("foo")))
-		buf.Write(getDataFrame([]byte("bar")))
-		qstr.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
-		qstr.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
-		data, err := io.ReadAll(s)
-		Expect(err).To(MatchError(errTooMuchData))
-		Expect(data).To(Equal([]byte("foob")))
-		// check that repeated calls to Read also return the right error
-		n, err := s.Read([]byte{0})
-		Expect(n).To(BeZero())
-		Expect(err).To(MatchError(errTooMuchData))
+	It("prevents duplicate calls to SendRequestHeader", func() {
+		req, err := http.NewRequest(http.MethodGet, "https://quic-go.net", nil)
+		Expect(err).ToNot(HaveOccurred())
+		qstr.EXPECT().Write(gomock.Any()).AnyTimes()
+		Expect(str.SendRequestHeader(req)).To(Succeed())
+		Expect(str.SendRequestHeader(req)).To(MatchError("http3: invalid duplicate use of SendRequestHeader"))
 	})
 
-	It("errors if more data than the maximum length is sent, as an additional frame", func() {
-		s := newLengthLimitedStream(str, 3)
-		buf.Write(getDataFrame([]byte("foo")))
-		buf.Write(getDataFrame([]byte("bar")))
-		qstr.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
-		qstr.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
-		data, err := io.ReadAll(s)
-		Expect(err).To(MatchError(errTooMuchData))
-		Expect(data).To(Equal([]byte("foo")))
+	It("reads after the response", func() {
+		req, err := http.NewRequest(http.MethodGet, "https://quic-go.net", nil)
+		Expect(err).ToNot(HaveOccurred())
+		qstr.EXPECT().Write(gomock.Any()).AnyTimes()
+		Expect(str.SendRequestHeader(req)).To(Succeed())
+
+		buf := bytes.NewBuffer(encodeResponse(200))
+		buf.Write((&dataFrame{Length: 6}).Append(nil))
+		buf.Write([]byte("foobar"))
+		qstr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
+		rsp, err := str.ReadResponse()
+		Expect(err).ToNot(HaveOccurred())
+		Expect(rsp.StatusCode).To(Equal(200))
+		b := make([]byte, 10)
+		n, err := str.Read(b)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(n).To(Equal(6))
+		Expect(b[:n]).To(Equal([]byte("foobar")))
 	})
 })
diff --git a/http3/mock_roundtripcloser_test.go b/http3/mock_roundtripcloser_test.go
deleted file mode 100644
index adc0c687..00000000
--- a/http3/mock_roundtripcloser_test.go
+++ /dev/null
@@ -1,155 +0,0 @@
-// Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/quic-go/quic-go/http3 (interfaces: RoundTripCloser)
-//
-// Generated by this command:
-//
-//	mockgen -typed -build_flags=-tags=gomock -package http3 -destination mock_roundtripcloser_test.go github.com/quic-go/quic-go/http3 RoundTripCloser
-//
-
-// Package http3 is a generated GoMock package.
-package http3
-
-import (
-	http "net/http"
-	reflect "reflect"
-
-	gomock "go.uber.org/mock/gomock"
-)
-
-// MockRoundTripCloser is a mock of RoundTripCloser interface.
-type MockRoundTripCloser struct {
-	ctrl     *gomock.Controller
-	recorder *MockRoundTripCloserMockRecorder
-}
-
-// MockRoundTripCloserMockRecorder is the mock recorder for MockRoundTripCloser.
-type MockRoundTripCloserMockRecorder struct {
-	mock *MockRoundTripCloser
-}
-
-// NewMockRoundTripCloser creates a new mock instance.
-func NewMockRoundTripCloser(ctrl *gomock.Controller) *MockRoundTripCloser {
-	mock := &MockRoundTripCloser{ctrl: ctrl}
-	mock.recorder = &MockRoundTripCloserMockRecorder{mock}
-	return mock
-}
-
-// EXPECT returns an object that allows the caller to indicate expected use.
-func (m *MockRoundTripCloser) EXPECT() *MockRoundTripCloserMockRecorder {
-	return m.recorder
-}
-
-// Close mocks base method.
-func (m *MockRoundTripCloser) Close() error {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "Close")
-	ret0, _ := ret[0].(error)
-	return ret0
-}
-
-// Close indicates an expected call of Close.
-func (mr *MockRoundTripCloserMockRecorder) Close() *MockRoundTripCloserCloseCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockRoundTripCloser)(nil).Close))
-	return &MockRoundTripCloserCloseCall{Call: call}
-}
-
-// MockRoundTripCloserCloseCall wrap *gomock.Call
-type MockRoundTripCloserCloseCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockRoundTripCloserCloseCall) Return(arg0 error) *MockRoundTripCloserCloseCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockRoundTripCloserCloseCall) Do(f func() error) *MockRoundTripCloserCloseCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockRoundTripCloserCloseCall) DoAndReturn(f func() error) *MockRoundTripCloserCloseCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// HandshakeComplete mocks base method.
-func (m *MockRoundTripCloser) HandshakeComplete() bool {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "HandshakeComplete")
-	ret0, _ := ret[0].(bool)
-	return ret0
-}
-
-// HandshakeComplete indicates an expected call of HandshakeComplete.
-func (mr *MockRoundTripCloserMockRecorder) HandshakeComplete() *MockRoundTripCloserHandshakeCompleteCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HandshakeComplete", reflect.TypeOf((*MockRoundTripCloser)(nil).HandshakeComplete))
-	return &MockRoundTripCloserHandshakeCompleteCall{Call: call}
-}
-
-// MockRoundTripCloserHandshakeCompleteCall wrap *gomock.Call
-type MockRoundTripCloserHandshakeCompleteCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockRoundTripCloserHandshakeCompleteCall) Return(arg0 bool) *MockRoundTripCloserHandshakeCompleteCall {
-	c.Call = c.Call.Return(arg0)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockRoundTripCloserHandshakeCompleteCall) Do(f func() bool) *MockRoundTripCloserHandshakeCompleteCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockRoundTripCloserHandshakeCompleteCall) DoAndReturn(f func() bool) *MockRoundTripCloserHandshakeCompleteCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
-
-// RoundTripOpt mocks base method.
-func (m *MockRoundTripCloser) RoundTripOpt(arg0 *http.Request, arg1 RoundTripOpt) (*http.Response, error) {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "RoundTripOpt", arg0, arg1)
-	ret0, _ := ret[0].(*http.Response)
-	ret1, _ := ret[1].(error)
-	return ret0, ret1
-}
-
-// RoundTripOpt indicates an expected call of RoundTripOpt.
-func (mr *MockRoundTripCloserMockRecorder) RoundTripOpt(arg0, arg1 any) *MockRoundTripCloserRoundTripOptCall {
-	mr.mock.ctrl.T.Helper()
-	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RoundTripOpt", reflect.TypeOf((*MockRoundTripCloser)(nil).RoundTripOpt), arg0, arg1)
-	return &MockRoundTripCloserRoundTripOptCall{Call: call}
-}
-
-// MockRoundTripCloserRoundTripOptCall wrap *gomock.Call
-type MockRoundTripCloserRoundTripOptCall struct {
-	*gomock.Call
-}
-
-// Return rewrite *gomock.Call.Return
-func (c *MockRoundTripCloserRoundTripOptCall) Return(arg0 *http.Response, arg1 error) *MockRoundTripCloserRoundTripOptCall {
-	c.Call = c.Call.Return(arg0, arg1)
-	return c
-}
-
-// Do rewrite *gomock.Call.Do
-func (c *MockRoundTripCloserRoundTripOptCall) Do(f func(*http.Request, RoundTripOpt) (*http.Response, error)) *MockRoundTripCloserRoundTripOptCall {
-	c.Call = c.Call.Do(f)
-	return c
-}
-
-// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockRoundTripCloserRoundTripOptCall) DoAndReturn(f func(*http.Request, RoundTripOpt) (*http.Response, error)) *MockRoundTripCloserRoundTripOptCall {
-	c.Call = c.Call.DoAndReturn(f)
-	return c
-}
diff --git a/http3/mock_singleroundtripper_test.go b/http3/mock_singleroundtripper_test.go
new file mode 100644
index 00000000..59dc3f53
--- /dev/null
+++ b/http3/mock_singleroundtripper_test.go
@@ -0,0 +1,119 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/quic-go/quic-go/http3 (interfaces: SingleRoundTripper)
+//
+// Generated by this command:
+//
+//	mockgen -typed -build_flags=-tags=gomock -package http3 -destination mock_singleroundtripper_test.go github.com/quic-go/quic-go/http3 SingleRoundTripper
+//
+
+// Package http3 is a generated GoMock package.
+package http3
+
+import (
+	context "context"
+	http "net/http"
+	reflect "reflect"
+
+	gomock "go.uber.org/mock/gomock"
+)
+
+// MockSingleRoundTripper is a mock of SingleRoundTripper interface.
+type MockSingleRoundTripper struct {
+	ctrl     *gomock.Controller
+	recorder *MockSingleRoundTripperMockRecorder
+}
+
+// MockSingleRoundTripperMockRecorder is the mock recorder for MockSingleRoundTripper.
+type MockSingleRoundTripperMockRecorder struct {
+	mock *MockSingleRoundTripper
+}
+
+// NewMockSingleRoundTripper creates a new mock instance.
+func NewMockSingleRoundTripper(ctrl *gomock.Controller) *MockSingleRoundTripper {
+	mock := &MockSingleRoundTripper{ctrl: ctrl}
+	mock.recorder = &MockSingleRoundTripperMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockSingleRoundTripper) EXPECT() *MockSingleRoundTripperMockRecorder {
+	return m.recorder
+}
+
+// OpenRequestStream mocks base method.
+func (m *MockSingleRoundTripper) OpenRequestStream(arg0 context.Context) (RequestStream, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "OpenRequestStream", arg0)
+	ret0, _ := ret[0].(RequestStream)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// OpenRequestStream indicates an expected call of OpenRequestStream.
+func (mr *MockSingleRoundTripperMockRecorder) OpenRequestStream(arg0 any) *MockSingleRoundTripperOpenRequestStreamCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenRequestStream", reflect.TypeOf((*MockSingleRoundTripper)(nil).OpenRequestStream), arg0)
+	return &MockSingleRoundTripperOpenRequestStreamCall{Call: call}
+}
+
+// MockSingleRoundTripperOpenRequestStreamCall wrap *gomock.Call
+type MockSingleRoundTripperOpenRequestStreamCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockSingleRoundTripperOpenRequestStreamCall) Return(arg0 RequestStream, arg1 error) *MockSingleRoundTripperOpenRequestStreamCall {
+	c.Call = c.Call.Return(arg0, arg1)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockSingleRoundTripperOpenRequestStreamCall) Do(f func(context.Context) (RequestStream, error)) *MockSingleRoundTripperOpenRequestStreamCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockSingleRoundTripperOpenRequestStreamCall) DoAndReturn(f func(context.Context) (RequestStream, error)) *MockSingleRoundTripperOpenRequestStreamCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
+
+// RoundTrip mocks base method.
+func (m *MockSingleRoundTripper) RoundTrip(arg0 *http.Request) (*http.Response, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "RoundTrip", arg0)
+	ret0, _ := ret[0].(*http.Response)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// RoundTrip indicates an expected call of RoundTrip.
+func (mr *MockSingleRoundTripperMockRecorder) RoundTrip(arg0 any) *MockSingleRoundTripperRoundTripCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RoundTrip", reflect.TypeOf((*MockSingleRoundTripper)(nil).RoundTrip), arg0)
+	return &MockSingleRoundTripperRoundTripCall{Call: call}
+}
+
+// MockSingleRoundTripperRoundTripCall wrap *gomock.Call
+type MockSingleRoundTripperRoundTripCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockSingleRoundTripperRoundTripCall) Return(arg0 *http.Response, arg1 error) *MockSingleRoundTripperRoundTripCall {
+	c.Call = c.Call.Return(arg0, arg1)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockSingleRoundTripperRoundTripCall) Do(f func(*http.Request) (*http.Response, error)) *MockSingleRoundTripperRoundTripCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockSingleRoundTripperRoundTripCall) DoAndReturn(f func(*http.Request) (*http.Response, error)) *MockSingleRoundTripperRoundTripCall {
+	c.Call = c.Call.DoAndReturn(f)
+	return c
+}
diff --git a/http3/request_writer_test.go b/http3/request_writer_test.go
index beabb378..9459db8d 100644
--- a/http3/request_writer_test.go
+++ b/http3/request_writer_test.go
@@ -6,7 +6,6 @@ import (
 	"net/http"
 
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
-	"github.com/quic-go/quic-go/internal/utils"
 
 	"github.com/quic-go/qpack"
 	"go.uber.org/mock/gomock"
@@ -41,7 +40,7 @@ var _ = Describe("Request Writer", func() {
 	}
 
 	BeforeEach(func() {
-		rw = newRequestWriter(utils.DefaultLogger)
+		rw = newRequestWriter()
 		strBuf = &bytes.Buffer{}
 		str = mockquic.NewMockStream(mockCtrl)
 		str.EXPECT().Write(gomock.Any()).DoAndReturn(strBuf.Write).AnyTimes()
diff --git a/http3/response_writer_test.go b/http3/response_writer_test.go
index ed6c1d28..290eb7f3 100644
--- a/http3/response_writer_test.go
+++ b/http3/response_writer_test.go
@@ -6,10 +6,8 @@ import (
 	"net/http"
 	"time"
 
-	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
-	"github.com/quic-go/quic-go/internal/utils"
-
 	"github.com/quic-go/qpack"
+	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -28,7 +26,7 @@ var _ = Describe("Response Writer", func() {
 		str.EXPECT().Write(gomock.Any()).DoAndReturn(strBuf.Write).AnyTimes()
 		str.EXPECT().SetReadDeadline(gomock.Any()).Return(nil).AnyTimes()
 		str.EXPECT().SetWriteDeadline(gomock.Any()).Return(nil).AnyTimes()
-		rw = newResponseWriter(str, nil, utils.DefaultLogger)
+		rw = newResponseWriter(newStream(str, nil), nil, false, nil)
 	})
 
 	decodeHeader := func(str io.Reader) map[string][]string {
diff --git a/http3/roundtrip_test.go b/http3/roundtrip_test.go
index 2df4294e..35b22af1 100644
--- a/http3/roundtrip_test.go
+++ b/http3/roundtrip_test.go
@@ -7,10 +7,11 @@ import (
 	"errors"
 	"io"
 	"net/http"
-	"sync/atomic"
 	"time"
 
 	"github.com/quic-go/quic-go"
+	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
+	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/qerr"
 
 	. "github.com/onsi/ginkgo/v2"
@@ -45,139 +46,291 @@ func (m *mockBody) Close() error {
 }
 
 var _ = Describe("RoundTripper", func() {
-	var (
-		rt  *RoundTripper
-		req *http.Request
-	)
+	var req *http.Request
 
 	BeforeEach(func() {
-		rt = &RoundTripper{}
 		var err error
 		req, err = http.NewRequest("GET", "https://www.example.org/file1.html", nil)
 		Expect(err).ToNot(HaveOccurred())
 	})
 
-	Context("dialing hosts", func() {
-		It("creates new clients", func() {
-			testErr := errors.New("test err")
-			req, err := http.NewRequest("GET", "https://quic.clemente.io/foobar.html", nil)
-			Expect(err).ToNot(HaveOccurred())
-			rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, testErr)
-				return cl, nil
-			}
-			_, err = rt.RoundTrip(req)
-			Expect(err).To(MatchError(testErr))
-		})
+	It("rejects quic.Configs that allow multiple QUIC versions", func() {
+		qconf := &quic.Config{
+			Versions: []quic.Version{protocol.Version2, protocol.Version1},
+		}
+		rt := &RoundTripper{QUICConfig: qconf}
+		_, err := rt.RoundTrip(req)
+		Expect(err).To(MatchError("can only use a single QUIC version for dialing a HTTP/3 connection"))
+	})
 
-		It("creates new clients with additional settings", func() {
-			testErr := errors.New("test err")
-			req, err := http.NewRequest("GET", "https://quic.clemente.io/foobar.html", nil)
-			Expect(err).ToNot(HaveOccurred())
-			rt.AdditionalSettings = map[uint64]uint64{1337: 42}
-			rt.newClient = func(_ string, _ *tls.Config, opts *roundTripperOpts, conf *quic.Config, _ dialFunc) (roundTripCloser, error) {
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, testErr)
-				Expect(opts.AdditionalSettings).To(HaveKeyWithValue(uint64(1337), uint64(42)))
-				return cl, nil
-			}
-			_, err = rt.RoundTrip(req)
-			Expect(err).To(MatchError(testErr))
-		})
+	It("uses the default QUIC and TLS config if none is give", func() {
+		var dialAddrCalled bool
+		rt := &RoundTripper{
+			Dial: func(_ context.Context, _ string, tlsConf *tls.Config, quicConf *quic.Config) (quic.EarlyConnection, error) {
+				defer GinkgoRecover()
+				Expect(quicConf.MaxIncomingStreams).To(Equal(defaultQuicConfig.MaxIncomingStreams))
+				Expect(tlsConf.NextProtos).To(Equal([]string{NextProtoH3}))
+				Expect(quicConf.Versions).To(Equal([]protocol.Version{protocol.Version1}))
+				dialAddrCalled = true
+				return nil, errors.New("test done")
+			},
+		}
+		_, err := rt.RoundTripOpt(req, RoundTripOpt{})
+		Expect(err).To(MatchError("test done"))
+		Expect(dialAddrCalled).To(BeTrue())
+	})
 
-		It("uses the quic.Config, if provided", func() {
-			config := &quic.Config{HandshakeIdleTimeout: time.Millisecond}
-			var receivedConfig *quic.Config
-			rt.Dial = func(_ context.Context, _ string, _ *tls.Config, config *quic.Config) (quic.EarlyConnection, error) {
-				receivedConfig = config
-				return nil, errors.New("handshake error")
-			}
-			rt.QUICConfig = config
-			_, err := rt.RoundTrip(req)
-			Expect(err).To(MatchError("handshake error"))
-			Expect(receivedConfig.HandshakeIdleTimeout).To(Equal(config.HandshakeIdleTimeout))
-		})
+	It("adds the port to the hostname, if none is given", func() {
+		var dialAddrCalled bool
+		rt := &RoundTripper{
+			Dial: func(_ context.Context, hostname string, _ *tls.Config, _ *quic.Config) (quic.EarlyConnection, error) {
+				defer GinkgoRecover()
+				Expect(hostname).To(Equal("quic.clemente.io:443"))
+				dialAddrCalled = true
+				return nil, errors.New("test done")
+			},
+		}
+		req, err := http.NewRequest("GET", "https://quic.clemente.io:443", nil)
+		Expect(err).ToNot(HaveOccurred())
+		_, err = rt.RoundTripOpt(req, RoundTripOpt{})
+		Expect(err).To(MatchError("test done"))
+		Expect(dialAddrCalled).To(BeTrue())
+	})
 
-		It("requires quic.Config.EnableDatagram if HTTP datagrams are enabled", func() {
-			rt.QUICConfig = &quic.Config{EnableDatagrams: false}
-			rt.Dial = func(_ context.Context, _ string, _ *tls.Config, config *quic.Config) (quic.EarlyConnection, error) {
-				return nil, errors.New("handshake error")
-			}
-			rt.EnableDatagrams = true
-			_, err := rt.RoundTrip(req)
-			Expect(err).To(MatchError("HTTP Datagrams enabled, but QUIC Datagrams disabled"))
-			rt.QUICConfig.EnableDatagrams = true
-			_, err = rt.RoundTrip(req)
-			Expect(err).To(MatchError("handshake error"))
-		})
+	It("sets the ServerName in the tls.Config, if not set", func() {
+		const host = "foo.bar"
+		var dialCalled bool
+		rt := &RoundTripper{
+			Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
+				defer GinkgoRecover()
+				Expect(tlsCfg.ServerName).To(Equal(host))
+				dialCalled = true
+				return nil, errors.New("test done")
+			},
+		}
+		req, err := http.NewRequest("GET", "https://foo.bar", nil)
+		Expect(err).ToNot(HaveOccurred())
+		_, err = rt.RoundTripOpt(req, RoundTripOpt{})
+		Expect(err).To(MatchError("test done"))
+		Expect(dialCalled).To(BeTrue())
+	})
+
+	It("uses the TLS config and QUIC config", func() {
+		tlsConf := &tls.Config{
+			ServerName: "foo.bar",
+			NextProtos: []string{"proto foo", "proto bar"},
+		}
+		quicConf := &quic.Config{MaxIdleTimeout: 3 * time.Nanosecond}
+		var dialAddrCalled bool
+		rt := &RoundTripper{
+			Dial: func(_ context.Context, host string, tlsConfP *tls.Config, quicConfP *quic.Config) (quic.EarlyConnection, error) {
+				defer GinkgoRecover()
+				Expect(host).To(Equal("www.example.org:443"))
+				Expect(tlsConfP.ServerName).To(Equal(tlsConf.ServerName))
+				Expect(tlsConfP.NextProtos).To(Equal([]string{NextProtoH3}))
+				Expect(quicConfP.MaxIdleTimeout).To(Equal(quicConf.MaxIdleTimeout))
+				dialAddrCalled = true
+				return nil, errors.New("test done")
+			},
+			QUICConfig:      quicConf,
+			TLSClientConfig: tlsConf,
+		}
+		_, err := rt.RoundTripOpt(req, RoundTripOpt{})
+		Expect(err).To(MatchError("test done"))
+		Expect(dialAddrCalled).To(BeTrue())
+		// make sure the original tls.Config was not modified
+		Expect(tlsConf.NextProtos).To(Equal([]string{"proto foo", "proto bar"}))
+	})
+
+	It("uses the custom dialer, if provided", func() {
+		testErr := errors.New("test done")
+		tlsConf := &tls.Config{ServerName: "foo.bar"}
+		quicConf := &quic.Config{MaxIdleTimeout: 1337 * time.Second}
+		// nolint:staticcheck // This is a test.
+		ctx := context.WithValue(context.Background(), "foo", "bar")
+		var dialerCalled bool
+		rt := &RoundTripper{
+			Dial: func(ctxP context.Context, address string, tlsConfP *tls.Config, quicConfP *quic.Config) (quic.EarlyConnection, error) {
+				defer GinkgoRecover()
+				Expect(ctx.Value("foo").(string)).To(Equal("bar"))
+				Expect(address).To(Equal("www.example.org:443"))
+				Expect(tlsConfP.ServerName).To(Equal("foo.bar"))
+				Expect(quicConfP.MaxIdleTimeout).To(Equal(quicConf.MaxIdleTimeout))
+				dialerCalled = true
+				return nil, testErr
+			},
+			TLSClientConfig: tlsConf,
+			QUICConfig:      quicConf,
+		}
+		_, err := rt.RoundTripOpt(req.WithContext(ctx), RoundTripOpt{})
+		Expect(err).To(MatchError(testErr))
+		Expect(dialerCalled).To(BeTrue())
+	})
+
+	It("enables HTTP/3 Datagrams", func() {
+		testErr := errors.New("handshake error")
+		rt := &RoundTripper{
+			EnableDatagrams: true,
+			Dial: func(_ context.Context, _ string, _ *tls.Config, quicConf *quic.Config) (quic.EarlyConnection, error) {
+				defer GinkgoRecover()
+				Expect(quicConf.EnableDatagrams).To(BeTrue())
+				return nil, testErr
+			},
+		}
+		_, err := rt.RoundTripOpt(req, RoundTripOpt{})
+		Expect(err).To(MatchError(testErr))
+	})
 
-		It("uses the custom dialer, if provided", func() {
-			var dialed bool
-			dialer := func(_ context.Context, _ string, tlsCfgP *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
-				dialed = true
+	It("requires quic.Config.EnableDatagram if HTTP/3 datagrams are enabled", func() {
+		rt := &RoundTripper{
+			QUICConfig:      &quic.Config{EnableDatagrams: false},
+			EnableDatagrams: true,
+			Dial: func(_ context.Context, _ string, _ *tls.Config, config *quic.Config) (quic.EarlyConnection, error) {
 				return nil, errors.New("handshake error")
-			}
-			rt.Dial = dialer
-			_, err := rt.RoundTrip(req)
-			Expect(err).To(MatchError("handshake error"))
-			Expect(dialed).To(BeTrue())
-		})
+			},
+		}
+		_, err := rt.RoundTrip(req)
+		Expect(err).To(MatchError("HTTP Datagrams enabled, but QUIC Datagrams disabled"))
+	})
+
+	It("creates new clients", func() {
+		testErr := errors.New("test err")
+		req1, err := http.NewRequest("GET", "https://quic-go.net/foobar.html", nil)
+		Expect(err).ToNot(HaveOccurred())
+		req2, err := http.NewRequest("GET", "https://example.com/foobar.html", nil)
+		Expect(err).ToNot(HaveOccurred())
+		var hostsDialed []string
+		rt := &RoundTripper{
+			Dial: func(_ context.Context, host string, _ *tls.Config, quicConf *quic.Config) (quic.EarlyConnection, error) {
+				hostsDialed = append(hostsDialed, host)
+				return nil, testErr
+			},
+		}
+		_, err = rt.RoundTrip(req1)
+		Expect(err).To(MatchError(testErr))
+		_, err = rt.RoundTrip(req2)
+		Expect(err).To(MatchError(testErr))
+		Expect(hostsDialed).To(Equal([]string{"quic-go.net:443", "example.com:443"}))
 	})
 
 	Context("reusing clients", func() {
-		var req1, req2 *http.Request
+		var (
+			rt         *RoundTripper
+			req1, req2 *http.Request
+			clientChan chan *MockSingleRoundTripper
+		)
 
 		BeforeEach(func() {
+			clientChan = make(chan *MockSingleRoundTripper, 16)
+			rt = &RoundTripper{
+				newClient: func(quic.EarlyConnection) singleRoundTripper {
+					select {
+					case c := <-clientChan:
+						return c
+					default:
+						Fail("no client")
+						return nil
+					}
+				},
+			}
 			var err error
-			req1, err = http.NewRequest("GET", "https://quic.clemente.io/file1.html", nil)
+			req1, err = http.NewRequest("GET", "https://quic-go.net/file1.html", nil)
 			Expect(err).ToNot(HaveOccurred())
-			req2, err = http.NewRequest("GET", "https://quic.clemente.io/file2.html", nil)
+			req2, err = http.NewRequest("GET", "https://quic-go.net/file2.html", nil)
 			Expect(err).ToNot(HaveOccurred())
 			Expect(req1.URL).ToNot(Equal(req2.URL))
 		})
 
 		It("reuses existing clients", func() {
+			cl := NewMockSingleRoundTripper(mockCtrl)
+			clientChan <- cl
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			handshakeChan := make(chan struct{})
+			close(handshakeChan)
+			conn.EXPECT().HandshakeComplete().Return(handshakeChan).MaxTimes(2)
+
+			cl.EXPECT().RoundTrip(req1).Return(&http.Response{Request: req1}, nil)
+			cl.EXPECT().RoundTrip(req2).Return(&http.Response{Request: req2}, nil)
 			var count int
-			rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
+			rt.Dial = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
 				count++
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
-					return &http.Response{Request: req}, nil
-				}).Times(2)
-				cl.EXPECT().HandshakeComplete().Return(true)
-				return cl, nil
+				return conn, nil
 			}
-			rsp1, err := rt.RoundTrip(req1)
+			rsp, err := rt.RoundTrip(req1)
 			Expect(err).ToNot(HaveOccurred())
-			Expect(rsp1.Request.URL).To(Equal(req1.URL))
-			rsp2, err := rt.RoundTrip(req2)
+			Expect(rsp.Request).To(Equal(req1))
+			rsp, err = rt.RoundTrip(req2)
 			Expect(err).ToNot(HaveOccurred())
-			Expect(rsp2.Request.URL).To(Equal(req2.URL))
+			Expect(rsp.Request).To(Equal(req2))
 			Expect(count).To(Equal(1))
 		})
 
 		It("immediately removes a clients when a request errored", func() {
-			testErr := errors.New("test err")
+			cl1 := NewMockSingleRoundTripper(mockCtrl)
+			clientChan <- cl1
+			cl2 := NewMockSingleRoundTripper(mockCtrl)
+			clientChan <- cl2
 
+			req1, err := http.NewRequest("GET", "https://quic-go.net/foobar.html", nil)
+			Expect(err).ToNot(HaveOccurred())
+			req2, err := http.NewRequest("GET", "https://quic-go.net/bar.html", nil)
+			Expect(err).ToNot(HaveOccurred())
+
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
 			var count int
-			rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
+			rt.Dial = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
 				count++
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, testErr)
-				return cl, nil
+				return conn, nil
 			}
-			_, err := rt.RoundTrip(req1)
-			Expect(err).To(MatchError(testErr))
-			_, err = rt.RoundTrip(req2)
+			testErr := errors.New("test err")
+			handshakeChan := make(chan struct{})
+			close(handshakeChan)
+			conn.EXPECT().HandshakeComplete().Return(handshakeChan).MaxTimes(2)
+			cl1.EXPECT().RoundTrip(req1).Return(nil, testErr)
+			cl2.EXPECT().RoundTrip(req2).Return(&http.Response{Request: req2}, nil)
+			_, err = rt.RoundTrip(req1)
 			Expect(err).To(MatchError(testErr))
+			rsp, err := rt.RoundTrip(req2)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(rsp.Request).To(Equal(req2))
 			Expect(count).To(Equal(2))
 		})
 
+		It("does not remove a client when a request returns context canceled error", func() {
+			cl1 := NewMockSingleRoundTripper(mockCtrl)
+			clientChan <- cl1
+			cl2 := NewMockSingleRoundTripper(mockCtrl)
+			clientChan <- cl2
+
+			req1, err := http.NewRequest("GET", "https://quic-go.net/foobar.html", nil)
+			Expect(err).ToNot(HaveOccurred())
+			req2, err := http.NewRequest("GET", "https://quic-go.net/bar.html", nil)
+			Expect(err).ToNot(HaveOccurred())
+
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			var count int
+			rt.Dial = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
+				count++
+				return conn, nil
+			}
+			testErr := context.Canceled
+			handshakeChan := make(chan struct{})
+			close(handshakeChan)
+			conn.EXPECT().HandshakeComplete().Return(handshakeChan).MaxTimes(2)
+			cl1.EXPECT().RoundTrip(req1).Return(nil, testErr)
+			cl1.EXPECT().RoundTrip(req2).Return(&http.Response{Request: req2}, nil)
+			_, err = rt.RoundTrip(req1)
+			Expect(err).To(MatchError(testErr))
+			rsp, err := rt.RoundTrip(req2)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(rsp.Request).To(Equal(req2))
+			Expect(count).To(Equal(1))
+		})
+
 		It("recreates a client when a request times out", func() {
 			var reqCount int
-			cl1 := NewMockRoundTripCloser(mockCtrl)
-			cl1.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
+			cl1 := NewMockSingleRoundTripper(mockCtrl)
+			cl1.EXPECT().RoundTrip(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) {
 				reqCount++
 				if reqCount == 1 { // the first request is successful...
 					Expect(req.URL).To(Equal(req1.URL))
@@ -187,19 +340,21 @@ var _ = Describe("RoundTripper", func() {
 				Expect(req.URL).To(Equal(req2.URL))
 				return nil, &qerr.IdleTimeoutError{}
 			}).Times(2)
-			cl1.EXPECT().HandshakeComplete().Return(true)
-			cl2 := NewMockRoundTripCloser(mockCtrl)
-			cl2.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
+			cl2 := NewMockSingleRoundTripper(mockCtrl)
+			cl2.EXPECT().RoundTrip(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) {
 				return &http.Response{Request: req}, nil
 			})
+			clientChan <- cl1
+			clientChan <- cl2
 
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			handshakeChan := make(chan struct{})
+			close(handshakeChan)
+			conn.EXPECT().HandshakeComplete().Return(handshakeChan).MaxTimes(2)
 			var count int
-			rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
+			rt.Dial = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
 				count++
-				if count == 1 {
-					return cl1, nil
-				}
-				return cl2, nil
+				return conn, nil
 			}
 			rsp1, err := rt.RoundTrip(req1)
 			Expect(err).ToNot(HaveOccurred())
@@ -211,11 +366,14 @@ var _ = Describe("RoundTripper", func() {
 
 		It("only issues a request once, even if a timeout error occurs", func() {
 			var count int
-			rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
+			rt.Dial = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
 				count++
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).Return(nil, &qerr.IdleTimeoutError{})
-				return cl, nil
+				return mockquic.NewMockEarlyConnection(mockCtrl), nil
+			}
+			rt.newClient = func(quic.EarlyConnection) singleRoundTripper {
+				cl := NewMockSingleRoundTripper(mockCtrl)
+				cl.EXPECT().RoundTrip(gomock.Any()).Return(nil, &qerr.IdleTimeoutError{})
+				return cl
 			}
 			_, err := rt.RoundTrip(req1)
 			Expect(err).To(MatchError(&qerr.IdleTimeoutError{}))
@@ -225,18 +383,23 @@ var _ = Describe("RoundTripper", func() {
 		It("handles a burst of requests", func() {
 			wait := make(chan struct{})
 			reqs := make(chan struct{}, 2)
+
+			cl := NewMockSingleRoundTripper(mockCtrl)
+			cl.EXPECT().RoundTrip(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) {
+				reqs <- struct{}{}
+				<-wait
+				return nil, &qerr.IdleTimeoutError{}
+			}).Times(2)
+			clientChan <- cl
+
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn.EXPECT().HandshakeComplete().Return(wait).AnyTimes()
 			var count int
-			rt.newClient = func(string, *tls.Config, *roundTripperOpts, *quic.Config, dialFunc) (roundTripCloser, error) {
+			rt.Dial = func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
 				count++
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(req *http.Request, _ RoundTripOpt) (*http.Response, error) {
-					reqs <- struct{}{}
-					<-wait
-					return nil, &qerr.IdleTimeoutError{}
-				}).Times(2)
-				cl.EXPECT().HandshakeComplete()
-				return cl, nil
+				return conn, nil
 			}
+
 			done := make(chan struct{}, 2)
 			go func() {
 				defer GinkgoRecover()
@@ -244,14 +407,14 @@ var _ = Describe("RoundTripper", func() {
 				_, err := rt.RoundTrip(req1)
 				Expect(err).To(MatchError(&qerr.IdleTimeoutError{}))
 			}()
+			// wait for the first requests to be issued
+			Eventually(reqs).Should(Receive())
 			go func() {
 				defer GinkgoRecover()
 				defer func() { done <- struct{}{} }()
 				_, err := rt.RoundTrip(req2)
 				Expect(err).To(MatchError(&qerr.IdleTimeoutError{}))
 			}()
-			// wait for both requests to be issued
-			Eventually(reqs).Should(Receive())
 			Eventually(reqs).Should(Receive())
 			close(wait) // now return the requests
 			Eventually(done).Should(Receive())
@@ -260,7 +423,7 @@ var _ = Describe("RoundTripper", func() {
 		})
 
 		It("doesn't create new clients if RoundTripOpt.OnlyCachedConn is set", func() {
-			req, err := http.NewRequest("GET", "https://quic.clemente.io/foobar.html", nil)
+			req, err := http.NewRequest("GET", "https://quic-go.net/foobar.html", nil)
 			Expect(err).ToNot(HaveOccurred())
 			_, err = rt.RoundTripOpt(req, RoundTripOpt{OnlyCachedConn: true})
 			Expect(err).To(MatchError(ErrNoCachedConn))
@@ -268,6 +431,8 @@ var _ = Describe("RoundTripper", func() {
 	})
 
 	Context("validating request", func() {
+		var rt RoundTripper
+
 		It("rejects plain HTTP requests", func() {
 			req, err := http.NewRequest("GET", "http://www.example.org/", nil)
 			req.Body = &mockBody{}
@@ -331,24 +496,66 @@ var _ = Describe("RoundTripper", func() {
 
 	Context("closing", func() {
 		It("closes", func() {
-			rt.clients = make(map[string]*roundTripCloserWithCount)
-			cl := NewMockRoundTripCloser(mockCtrl)
-			cl.EXPECT().Close()
-			rt.clients["foo.bar"] = &roundTripCloserWithCount{cl, atomic.Int64{}}
-			err := rt.Close()
+			conn := mockquic.NewMockEarlyConnection(mockCtrl)
+			rt := &RoundTripper{
+				Dial: func(context.Context, string, *tls.Config, *quic.Config) (quic.EarlyConnection, error) {
+					return conn, nil
+				},
+				newClient: func(quic.EarlyConnection) singleRoundTripper {
+					cl := NewMockSingleRoundTripper(mockCtrl)
+					cl.EXPECT().RoundTrip(gomock.Any()).Return(&http.Response{}, nil)
+					return cl
+				},
+			}
+			req, err := http.NewRequest("GET", "https://quic-go.net/foobar.html", nil)
+			Expect(err).ToNot(HaveOccurred())
+			_, err = rt.RoundTrip(req)
 			Expect(err).ToNot(HaveOccurred())
-			Expect(len(rt.clients)).To(BeZero())
+			conn.EXPECT().CloseWithError(quic.ApplicationErrorCode(0), "")
+			Expect(rt.Close()).To(Succeed())
 		})
 
-		It("closes a RoundTripper that has never been used", func() {
-			Expect(len(rt.clients)).To(BeZero())
-			err := rt.Close()
+		It("closes while dialing", func() {
+			rt := &RoundTripper{
+				Dial: func(ctx context.Context, _ string, _ *tls.Config, _ *quic.Config) (quic.EarlyConnection, error) {
+					defer GinkgoRecover()
+					Eventually(ctx.Done()).Should(BeClosed())
+					return nil, errors.New("cancelled")
+				},
+			}
+			req, err := http.NewRequest("GET", "https://quic-go.net/foobar.html", nil)
 			Expect(err).ToNot(HaveOccurred())
-			Expect(len(rt.clients)).To(BeZero())
+
+			errChan := make(chan error, 1)
+			go func() {
+				defer GinkgoRecover()
+				_, err := rt.RoundTrip(req)
+				errChan <- err
+			}()
+
+			Consistently(errChan, scaleDuration(30*time.Millisecond)).ShouldNot(Receive())
+			Expect(rt.Close()).To(Succeed())
+			var rtErr error
+			Eventually(errChan).Should(Receive(&rtErr))
+			Expect(rtErr).To(MatchError("cancelled"))
 		})
 
 		It("closes idle connections", func() {
-			Expect(len(rt.clients)).To(Equal(0))
+			conn1 := mockquic.NewMockEarlyConnection(mockCtrl)
+			conn2 := mockquic.NewMockEarlyConnection(mockCtrl)
+			rt := &RoundTripper{
+				Dial: func(_ context.Context, hostname string, _ *tls.Config, _ *quic.Config) (quic.EarlyConnection, error) {
+					switch hostname {
+					case "site1.com:443":
+						return conn1, nil
+					case "site2.com:443":
+						return conn2, nil
+					default:
+						Fail("unexpected hostname")
+						return nil, errors.New("unexpected hostname")
+					}
+				},
+			}
 			req1, err := http.NewRequest("GET", "https://site1.com", nil)
 			Expect(err).ToNot(HaveOccurred())
 			req2, err := http.NewRequest("GET", "https://site2.com", nil)
@@ -360,15 +567,14 @@ var _ = Describe("RoundTripper", func() {
 			req2 = req2.WithContext(ctx2)
 			roundTripCalled := make(chan struct{})
 			reqFinished := make(chan struct{})
-			rt.newClient = func(hostname string, tlsConf *tls.Config, opts *roundTripperOpts, conf *quic.Config, dialer dialFunc) (roundTripCloser, error) {
-				cl := NewMockRoundTripCloser(mockCtrl)
-				cl.EXPECT().Close()
-				cl.EXPECT().RoundTripOpt(gomock.Any(), gomock.Any()).DoAndReturn(func(r *http.Request, _ RoundTripOpt) (*http.Response, error) {
+			rt.newClient = func(quic.EarlyConnection) singleRoundTripper {
+				cl := NewMockSingleRoundTripper(mockCtrl)
+				cl.EXPECT().RoundTrip(gomock.Any()).DoAndReturn(func(r *http.Request) (*http.Response, error) {
 					roundTripCalled <- struct{}{}
 					<-r.Context().Done()
 					return nil, nil
 				})
-				return cl, nil
+				return cl
 			}
 			go func() {
 				rt.RoundTrip(req1)
@@ -381,17 +587,16 @@ var _ = Describe("RoundTripper", func() {
 			<-roundTripCalled
 			<-roundTripCalled
 			// Both two requests are started.
-			Expect(len(rt.clients)).To(Equal(2))
 			cancel1()
 			<-reqFinished
 			// req1 is finished
+			conn1.EXPECT().CloseWithError(gomock.Any(), gomock.Any())
 			rt.CloseIdleConnections()
-			Expect(len(rt.clients)).To(Equal(1))
 			cancel2()
 			<-reqFinished
 			// all requests are finished
+			conn2.EXPECT().CloseWithError(gomock.Any(), gomock.Any())
 			rt.CloseIdleConnections()
-			Expect(len(rt.clients)).To(Equal(0))
 		})
 	})
 })
diff --git a/http3/server_test.go b/http3/server_test.go
index c7ea5a2f..d8c9c358 100644
--- a/http3/server_test.go
+++ b/http3/server_test.go
@@ -6,6 +6,7 @@ import (
 	"crypto/tls"
 	"errors"
 	"io"
+	"log/slog"
 	"net"
 	"net/http"
 	"runtime"
@@ -16,7 +17,6 @@ import (
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
 	"github.com/quic-go/quic-go/internal/protocol"
 	"github.com/quic-go/quic-go/internal/testdata"
-	"github.com/quic-go/quic-go/internal/utils"
 	"github.com/quic-go/quic-go/quicvarint"
 
 	"github.com/quic-go/qpack"
@@ -71,7 +71,6 @@ var _ = Describe("Server", func() {
 	BeforeEach(func() {
 		s = &Server{
 			TLSConfig: testdata.GetTLSConfig(),
-			logger:    utils.DefaultLogger,
 			ConnContext: func(ctx context.Context, c quic.Connection) context.Context {
 				return context.WithValue(ctx, testConnContextKey("test"), c)
 			},
@@ -87,7 +86,7 @@ var _ = Describe("Server", func() {
 		var (
 			qpackDecoder       *qpack.Decoder
 			str                *mockquic.MockStream
-			conn               *mockquic.MockEarlyConnection
+			conn               *connection
 			exampleGetRequest  *http.Request
 			examplePostRequest *http.Request
 		)
@@ -116,7 +115,7 @@ var _ = Describe("Server", func() {
 			buf := &bytes.Buffer{}
 			str := mockquic.NewMockStream(mockCtrl)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
-			rw := newRequestWriter(utils.DefaultLogger)
+			rw := newRequestWriter()
 			Expect(rw.WriteRequestHeader(str, req, false)).To(Succeed())
 			return buf.Bytes()
 		}
@@ -140,11 +139,13 @@ var _ = Describe("Server", func() {
 
 			qpackDecoder = qpack.NewDecoder(nil)
 			str = mockquic.NewMockStream(mockCtrl)
-			conn = mockquic.NewMockEarlyConnection(mockCtrl)
+			qconn := mockquic.NewMockEarlyConnection(mockCtrl)
 			addr := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 1337}
-			conn.EXPECT().RemoteAddr().Return(addr).AnyTimes()
-			conn.EXPECT().LocalAddr().AnyTimes()
-			conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}).AnyTimes()
+			qconn.EXPECT().RemoteAddr().Return(addr).AnyTimes()
+			qconn.EXPECT().LocalAddr().AnyTimes()
+			qconn.EXPECT().ConnectionState().Return(quic.ConnectionState{}).AnyTimes()
+			qconn.EXPECT().Context().Return(context.Background()).AnyTimes()
+			conn = newConnection(qconn, false, nil, protocol.PerspectiveServer, nil)
 		})
 
 		It("calls the HTTP handler function", func() {
@@ -159,8 +160,9 @@ var _ = Describe("Server", func() {
 				return len(p), nil
 			}).AnyTimes()
 			str.EXPECT().CancelRead(gomock.Any())
+			str.EXPECT().Close()
 
-			Expect(s.handleRequest(conn, str, qpackDecoder, nil)).To(Equal(requestError{}))
+			s.handleRequest(conn, str, qpackDecoder)
 			var req *http.Request
 			Eventually(requestChan).Should(Receive(&req))
 			Expect(req.Host).To(Equal("www.example.com"))
@@ -177,9 +179,9 @@ var _ = Describe("Server", func() {
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
 			str.EXPECT().CancelRead(gomock.Any())
+			str.EXPECT().Close()
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			s.handleRequest(conn, str, qpackDecoder)
 			hfs := decodeHeader(responseBuf)
 			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
 		})
@@ -194,9 +196,9 @@ var _ = Describe("Server", func() {
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
 			str.EXPECT().CancelRead(gomock.Any())
+			str.EXPECT().Close()
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			s.handleRequest(conn, str, qpackDecoder)
 			hfs := decodeHeader(responseBuf)
 			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
 			Expect(hfs).To(HaveKeyWithValue("content-length", []string{"6"}))
@@ -216,32 +218,33 @@ var _ = Describe("Server", func() {
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
 			str.EXPECT().CancelRead(gomock.Any())
+			str.EXPECT().Close()
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			s.handleRequest(conn, str, qpackDecoder)
 			hfs := decodeHeader(responseBuf)
 			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
 			// status, date, content-type
 			Expect(hfs).To(HaveLen(3))
 		})
 
-		It("response to HEAD request should not have body", func() {
+		It("ignores calls to Write for responses to HEAD requests", func() {
 			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				w.Write([]byte("foobar"))
 			})
 
-			headRequest, err := http.NewRequest("HEAD", "https://www.example.com", nil)
+			headRequest, err := http.NewRequest(http.MethodHead, "https://www.example.com", nil)
 			Expect(err).ToNot(HaveOccurred())
 			responseBuf := &bytes.Buffer{}
 			setRequest(encodeRequest(headRequest))
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
 			str.EXPECT().CancelRead(gomock.Any())
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			str.EXPECT().Close()
+
+			s.handleRequest(conn, str, qpackDecoder)
 			hfs := decodeHeader(responseBuf)
 			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
-			Expect(responseBuf.Bytes()).To(HaveLen(0))
+			Expect(responseBuf.Bytes()).To(BeEmpty())
 		})
 
 		It("response to HEAD request should also do content sniffing", func() {
@@ -249,22 +252,23 @@ var _ = Describe("Server", func() {
 				w.Write([]byte("<html></html>"))
 			})
 
-			headRequest, err := http.NewRequest("HEAD", "https://www.example.com", nil)
+			headRequest, err := http.NewRequest(http.MethodHead, "https://www.example.com", nil)
 			Expect(err).ToNot(HaveOccurred())
 			responseBuf := &bytes.Buffer{}
 			setRequest(encodeRequest(headRequest))
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
 			str.EXPECT().CancelRead(gomock.Any())
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			str.EXPECT().Close()
+
+			s.handleRequest(conn, str, qpackDecoder)
 			hfs := decodeHeader(responseBuf)
 			Expect(hfs).To(HaveKeyWithValue(":status", []string{"200"}))
 			Expect(hfs).To(HaveKeyWithValue("content-length", []string{"13"}))
 			Expect(hfs).To(HaveKeyWithValue("content-type", []string{"text/html; charset=utf-8"}))
 		})
 
-		It("handles a aborting handler", func() {
+		It("handles an aborting handler", func() {
 			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				panic(http.ErrAbortHandler)
 			})
@@ -273,14 +277,16 @@ var _ = Describe("Server", func() {
 			setRequest(encodeRequest(exampleGetRequest))
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
-			str.EXPECT().CancelRead(gomock.Any())
+			str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeInternalError))
+			str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeInternalError))
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).To(MatchError(errPanicked))
+			s.handleRequest(conn, str, qpackDecoder)
 			Expect(responseBuf.Bytes()).To(HaveLen(0))
 		})
 
 		It("handles a panicking handler", func() {
+			var logBuf bytes.Buffer
+			s.Logger = slog.New(slog.NewTextHandler(&logBuf, nil))
 			s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 				panic("foobar")
 			})
@@ -289,11 +295,13 @@ var _ = Describe("Server", func() {
 			setRequest(encodeRequest(exampleGetRequest))
 			str.EXPECT().Context().Return(reqContext)
 			str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
-			str.EXPECT().CancelRead(gomock.Any())
+			str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeInternalError))
+			str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeInternalError))
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).To(MatchError(errPanicked))
+			s.handleRequest(conn, str, qpackDecoder)
 			Expect(responseBuf.Bytes()).To(HaveLen(0))
+			Expect(logBuf.String()).To(ContainSubstring("http: panic serving"))
+			Expect(logBuf.String()).To(ContainSubstring("foobar"))
 		})
 
 		Context("hijacking bidirectional streams", func() {
@@ -313,9 +321,12 @@ var _ = Describe("Server", func() {
 			AfterEach(func() { testDone <- struct{}{} })
 
 			It("hijacks a bidirectional stream of unknown frame type", func() {
+				id := quic.ConnectionTracingID(1337)
 				frameTypeChan := make(chan FrameType, 1)
-				s.StreamHijacker = func(ft FrameType, c quic.Connection, s quic.Stream, e error) (hijacked bool, err error) {
+				s.StreamHijacker = func(ft FrameType, connTracingID quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
+					defer GinkgoRecover()
 					Expect(e).ToNot(HaveOccurred())
+					Expect(connTracingID).To(Equal(id))
 					frameTypeChan <- ft
 					return true, nil
 				}
@@ -329,6 +340,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -336,7 +349,7 @@ var _ = Describe("Server", func() {
 
 			It("cancels writing when hijacker didn't hijack a bidirectional stream", func() {
 				frameTypeChan := make(chan FrameType, 1)
-				s.StreamHijacker = func(ft FrameType, c quic.Connection, s quic.Stream, e error) (hijacked bool, err error) {
+				s.StreamHijacker = func(ft FrameType, _ quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
 					Expect(e).ToNot(HaveOccurred())
 					frameTypeChan <- ft
 					return false, nil
@@ -345,6 +358,7 @@ var _ = Describe("Server", func() {
 				buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
 				unknownStr := mockquic.NewMockStream(mockCtrl)
 				unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
+				unknownStr.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 				unknownStr.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 				conn.EXPECT().AcceptStream(gomock.Any()).Return(unknownStr, nil)
 				conn.EXPECT().AcceptStream(gomock.Any()).Return(nil, errors.New("done"))
@@ -352,6 +366,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -359,7 +375,7 @@ var _ = Describe("Server", func() {
 
 			It("cancels writing when hijacker returned error", func() {
 				frameTypeChan := make(chan FrameType, 1)
-				s.StreamHijacker = func(ft FrameType, c quic.Connection, s quic.Stream, e error) (hijacked bool, err error) {
+				s.StreamHijacker = func(ft FrameType, _ quic.ConnectionTracingID, _ quic.Stream, e error) (hijacked bool, err error) {
 					Expect(e).ToNot(HaveOccurred())
 					frameTypeChan <- ft
 					return false, errors.New("error in hijacker")
@@ -368,6 +384,7 @@ var _ = Describe("Server", func() {
 				buf := bytes.NewBuffer(quicvarint.Append(nil, 0x41))
 				unknownStr := mockquic.NewMockStream(mockCtrl)
 				unknownStr.EXPECT().Read(gomock.Any()).DoAndReturn(buf.Read).AnyTimes()
+				unknownStr.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 				unknownStr.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 				conn.EXPECT().AcceptStream(gomock.Any()).Return(unknownStr, nil)
 				conn.EXPECT().AcceptStream(gomock.Any()).Return(nil, errors.New("done"))
@@ -375,6 +392,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(frameTypeChan).Should(Receive(BeEquivalentTo(0x41)))
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -384,7 +403,7 @@ var _ = Describe("Server", func() {
 				testErr := errors.New("test error")
 				done := make(chan struct{})
 				unknownStr := mockquic.NewMockStream(mockCtrl)
-				s.StreamHijacker = func(ft FrameType, _ quic.Connection, str quic.Stream, err error) (bool, error) {
+				s.StreamHijacker = func(ft FrameType, _ quic.ConnectionTracingID, str quic.Stream, err error) (bool, error) {
 					defer close(done)
 					Expect(ft).To(BeZero())
 					Expect(str).To(Equal(unknownStr))
@@ -399,6 +418,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(done).Should(BeClosed())
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -423,9 +444,11 @@ var _ = Describe("Server", func() {
 			AfterEach(func() { testDone <- struct{}{} })
 
 			It("hijacks an unidirectional stream of unknown stream type", func() {
+				id := quic.ConnectionTracingID(42)
 				streamTypeChan := make(chan StreamType, 1)
-				s.UniStreamHijacker = func(st StreamType, _ quic.Connection, _ quic.ReceiveStream, err error) bool {
+				s.UniStreamHijacker = func(st StreamType, connTracingID quic.ConnectionTracingID, _ quic.ReceiveStream, err error) bool {
 					Expect(err).ToNot(HaveOccurred())
+					Expect(connTracingID).To(Equal(id))
 					streamTypeChan <- st
 					return true
 				}
@@ -440,6 +463,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, id)
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(streamTypeChan).Should(Receive(BeEquivalentTo(0x54)))
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -449,7 +474,7 @@ var _ = Describe("Server", func() {
 				testErr := errors.New("test error")
 				done := make(chan struct{})
 				unknownStr := mockquic.NewMockStream(mockCtrl)
-				s.UniStreamHijacker = func(st StreamType, _ quic.Connection, str quic.ReceiveStream, err error) bool {
+				s.UniStreamHijacker = func(st StreamType, _ quic.ConnectionTracingID, str quic.ReceiveStream, err error) bool {
 					defer close(done)
 					Expect(st).To(BeZero())
 					Expect(str).To(Equal(unknownStr))
@@ -463,6 +488,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(done).Should(BeClosed())
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -470,7 +497,7 @@ var _ = Describe("Server", func() {
 
 			It("cancels reading when hijacker didn't hijack an unidirectional stream", func() {
 				streamTypeChan := make(chan StreamType, 1)
-				s.UniStreamHijacker = func(st StreamType, _ quic.Connection, _ quic.ReceiveStream, err error) bool {
+				s.UniStreamHijacker = func(st StreamType, _ quic.ConnectionTracingID, _ quic.ReceiveStream, err error) bool {
 					Expect(err).ToNot(HaveOccurred())
 					streamTypeChan <- st
 					return false
@@ -488,6 +515,8 @@ var _ = Describe("Server", func() {
 					<-testDone
 					return nil, errors.New("test done")
 				})
+				ctx := context.WithValue(context.Background(), quic.ConnectionTracingKey, quic.ConnectionTracingID(1234))
+				conn.EXPECT().Context().Return(ctx).AnyTimes()
 				s.handleConn(conn)
 				Eventually(streamTypeChan).Should(Receive(BeEquivalentTo(0x54)))
 				time.Sleep(scaleDuration(20 * time.Millisecond)) // don't EXPECT any calls to conn.CloseWithError
@@ -514,6 +543,7 @@ var _ = Describe("Server", func() {
 				conn.EXPECT().RemoteAddr().Return(addr).AnyTimes()
 				conn.EXPECT().LocalAddr().AnyTimes()
 				conn.EXPECT().ConnectionState().Return(quic.ConnectionState{}).AnyTimes()
+				conn.EXPECT().Context().Return(context.Background()).AnyTimes()
 			})
 
 			AfterEach(func() { testDone <- struct{}{} })
@@ -574,6 +604,7 @@ var _ = Describe("Server", func() {
 				setRequest(append(requestData, b...))
 				done := make(chan struct{})
 				str.EXPECT().Write(gomock.Any()).DoAndReturn(responseBuf.Write).AnyTimes()
+				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
 				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeFrameError)).Do(func(quic.StreamErrorCode) { close(done) })
 
 				s.handleConn(conn)
@@ -589,6 +620,7 @@ var _ = Describe("Server", func() {
 				testErr := errors.New("stream reset")
 				done := make(chan struct{})
 				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
+				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeRequestIncomplete))
 				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeRequestIncomplete)).Do(func(quic.StreamErrorCode) { close(done) })
 
 				s.handleConn(conn)
@@ -616,23 +648,23 @@ var _ = Describe("Server", func() {
 				Eventually(done).Should(BeClosed())
 			})
 
-			It("closes the connection when the first frame is not a HEADERS frame", func() {
+			It("rejects a request that has too large request headers", func() {
 				handlerCalled := make(chan struct{})
 				s.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 					close(handlerCalled)
 				})
 
-				// use 2*DefaultMaxHeaderBytes here. qpack will compress the requiest,
+				// use 2*DefaultMaxHeaderBytes here. qpack will compress the request,
 				// but the request will still end up larger than DefaultMaxHeaderBytes.
 				url := bytes.Repeat([]byte{'a'}, http.DefaultMaxHeaderBytes*2)
 				req, err := http.NewRequest(http.MethodGet, "https://"+string(url), nil)
 				Expect(err).ToNot(HaveOccurred())
 				setRequest(encodeRequest(req))
-				// str.EXPECT().Context().Return(reqContext)
 				str.EXPECT().Write(gomock.Any()).DoAndReturn(func(p []byte) (int, error) {
 					return len(p), nil
 				}).AnyTimes()
 				done := make(chan struct{})
+				str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeFrameError))
 				str.EXPECT().CancelWrite(quic.StreamErrorCode(ErrCodeFrameError)).Do(func(quic.StreamErrorCode) { close(done) })
 
 				s.handleConn(conn)
@@ -656,9 +688,9 @@ var _ = Describe("Server", func() {
 				return len(p), nil
 			}).AnyTimes()
 			str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeNoError))
+			str.EXPECT().Close()
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			s.handleRequest(conn, str, qpackDecoder)
 			Eventually(handlerCalled).Should(BeClosed())
 		})
 
@@ -679,9 +711,9 @@ var _ = Describe("Server", func() {
 				return len(p), nil
 			}).AnyTimes()
 			str.EXPECT().CancelRead(quic.StreamErrorCode(ErrCodeNoError))
+			str.EXPECT().Close()
 
-			serr := s.handleRequest(conn, str, qpackDecoder, nil)
-			Expect(serr.err).ToNot(HaveOccurred())
+			s.handleRequest(conn, str, qpackDecoder)
 			Eventually(handlerCalled).Should(BeClosed())
 		})
 	})
@@ -766,6 +798,8 @@ var _ = Describe("Server", func() {
 
 		It("uses s.Addr if listeners don't have ports available", func() {
 			s.Addr = ":443"
+			var logBuf bytes.Buffer
+			s.Logger = slog.New(slog.NewTextHandler(&logBuf, nil))
 			mln := &noPortListener{newMockAddrListener("")}
 			mln.EXPECT().Addr()
 			ln1 = mln
@@ -773,6 +807,7 @@ var _ = Describe("Server", func() {
 			checkSetHeaders(Equal(expected))
 			s.removeListener(&ln1)
 			checkSetHeaderError()
+			Expect(logBuf.String()).To(ContainSubstring("Unable to extract port from listener, will not be announced using SetQUICHeaders"))
 		})
 
 		It("properly announces multiple listeners", func() {
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index 8e57d906..cf0cfb6f 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -11,6 +11,8 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"net/http/httptrace"
+	"net/textproto"
 	"os"
 	"strconv"
 	"sync/atomic"
@@ -118,7 +120,6 @@ var _ = Describe("HTTP tests", func() {
 			TLSClientConfig: getTLSClientConfigWithoutServerName(),
 			QUICConfig: getQuicConfig(&quic.Config{
 				MaxIdleTimeout: 10 * time.Second,
-				Allow0RTT:      true,
 			}),
 			DisableCompression: true,
 		}
@@ -137,13 +138,14 @@ var _ = Describe("HTTP tests", func() {
 	It("sets content-length for small response", func() {
 		mux.HandleFunc("/small", func(w http.ResponseWriter, r *http.Request) {
 			defer GinkgoRecover()
-			w.Write([]byte("foobar"))
+			w.Write([]byte("foo"))
+			w.Write([]byte("bar"))
 		})
 
 		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/small", port))
 		Expect(err).ToNot(HaveOccurred())
 		Expect(resp.StatusCode).To(Equal(200))
-		Expect(resp.Header.Get("Content-Length")).To(Equal(strconv.Itoa(len("foobar"))))
+		Expect(resp.Header.Get("Content-Length")).To(Equal("6"))
 	})
 
 	It("detects stream errors when server panics when writing response", func() {
@@ -184,6 +186,7 @@ var _ = Describe("HTTP tests", func() {
 		group, ctx := errgroup.WithContext(context.Background())
 		for i := 0; i < 2; i++ {
 			group.Go(func() error {
+				defer GinkgoRecover()
 				req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://localhost:%d/hello", port), nil)
 				Expect(err).ToNot(HaveOccurred())
 				resp, err := client.Do(req)
@@ -432,11 +435,24 @@ var _ = Describe("HTTP tests", func() {
 
 		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/httpstreamer", port), nil)
 		Expect(err).ToNot(HaveOccurred())
-		rsp, err := client.Transport.(*http3.RoundTripper).RoundTripOpt(req, http3.RoundTripOpt{DontCloseRequestStream: true})
+		tlsConf := getTLSClientConfigWithoutServerName()
+		tlsConf.NextProtos = []string{http3.NextProtoH3}
+		conn, err := quic.DialAddr(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", port),
+			tlsConf,
+			getQuicConfig(nil),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer conn.CloseWithError(0, "")
+		rt := http3.SingleDestinationRoundTripper{Connection: conn}
+		str, err := rt.OpenRequestStream(context.Background())
+		Expect(err).ToNot(HaveOccurred())
+		Expect(str.SendRequestHeader(req)).To(Succeed())
+		rsp, err := str.ReadResponse()
 		Expect(err).ToNot(HaveOccurred())
 		Expect(rsp.StatusCode).To(Equal(200))
 
-		str := rsp.Body.(http3.HTTPStreamer).HTTPStream()
 		b := make([]byte, 6)
 		_, err = io.ReadFull(str, b)
 		Expect(err).ToNot(HaveOccurred())
@@ -451,10 +467,10 @@ var _ = Describe("HTTP tests", func() {
 		Expect(repl).To(Equal(data))
 	})
 
-	It("serves other QUIC connections", func() {
+	It("serves QUIC connections", func() {
 		tlsConf := getTLSConfig()
 		tlsConf.NextProtos = []string{http3.NextProtoH3}
-		ln, err := quic.ListenAddr("localhost:0", tlsConf, nil)
+		ln, err := quic.ListenAddr("localhost:0", tlsConf, getQuicConfig(nil))
 		Expect(err).ToNot(HaveOccurred())
 		defer ln.Close()
 		done := make(chan struct{})
@@ -463,7 +479,7 @@ var _ = Describe("HTTP tests", func() {
 			defer close(done)
 			conn, err := ln.Accept(context.Background())
 			Expect(err).ToNot(HaveOccurred())
-			Expect(server.ServeQUICConn(conn)).To(Succeed())
+			server.ServeQUICConn(conn) // returns once the client closes
 		}()
 
 		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/hello", ln.Addr().(*net.UDPAddr).Port))
@@ -537,6 +553,7 @@ var _ = Describe("HTTP tests", func() {
 
 	It("sets conn context", func() {
 		type ctxKey int
+		var tracingID quic.ConnectionTracingID
 		server.ConnContext = func(ctx context.Context, c quic.Connection) context.Context {
 			serv, ok := ctx.Value(http3.ServerContextKey).(*http3.Server)
 			Expect(ok).To(BeTrue())
@@ -544,6 +561,7 @@ var _ = Describe("HTTP tests", func() {
 
 			ctx = context.WithValue(ctx, ctxKey(0), "Hello")
 			ctx = context.WithValue(ctx, ctxKey(1), c)
+			tracingID = c.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID)
 			return ctx
 		}
 		mux.HandleFunc("/conn-context", func(w http.ResponseWriter, r *http.Request) {
@@ -559,6 +577,10 @@ var _ = Describe("HTTP tests", func() {
 			serv, ok := r.Context().Value(http3.ServerContextKey).(*http3.Server)
 			Expect(ok).To(BeTrue())
 			Expect(serv).To(Equal(server))
+
+			id, ok := r.Context().Value(quic.ConnectionTracingKey).(quic.ConnectionTracingID)
+			Expect(ok).To(BeTrue())
+			Expect(id).To(Equal(tracingID))
 		})
 
 		resp, err := client.Get(fmt.Sprintf("https://localhost:%d/conn-context", port))
@@ -567,16 +589,130 @@ var _ = Describe("HTTP tests", func() {
 	})
 
 	It("checks the server's settings", func() {
-		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/hello", port), nil)
-		Expect(err).ToNot(HaveOccurred())
-		testErr := errors.New("test error")
-		_, err = rt.RoundTripOpt(req, http3.RoundTripOpt{CheckSettings: func(settings http3.Settings) error {
-			Expect(settings.EnableExtendedConnect).To(BeTrue())
-			Expect(settings.EnableDatagram).To(BeFalse())
-			Expect(settings.Other).To(BeEmpty())
-			return testErr
-		}})
-		Expect(err).To(MatchError(err))
+		tlsConf := tlsClientConfigWithoutServerName.Clone()
+		tlsConf.NextProtos = []string{http3.NextProtoH3}
+		conn, err := quic.DialAddr(
+			context.Background(),
+			fmt.Sprintf("localhost:%d", port),
+			tlsConf,
+			getQuicConfig(nil),
+		)
+		Expect(err).ToNot(HaveOccurred())
+		defer conn.CloseWithError(0, "")
+		rt := http3.SingleDestinationRoundTripper{Connection: conn}
+		hconn := rt.Start()
+		Eventually(hconn.ReceivedSettings(), 5*time.Second, 10*time.Millisecond).Should(BeClosed())
+		settings := hconn.Settings()
+		Expect(settings.EnableExtendedConnect).To(BeTrue())
+		Expect(settings.EnableDatagram).To(BeFalse())
+		Expect(settings.Other).To(BeEmpty())
+	})
+
+	It("receives the client's settings", func() {
+		settingsChan := make(chan *http3.Settings, 1)
+		mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) {
+			defer GinkgoRecover()
+			conn := w.(http3.Hijacker).Connection()
+			Eventually(conn.ReceivedSettings(), 5*time.Second, 10*time.Millisecond).Should(BeClosed())
+			settingsChan <- conn.Settings()
+			w.WriteHeader(http.StatusOK)
+		})
+
+		rt = &http3.RoundTripper{
+			TLSClientConfig: getTLSClientConfigWithoutServerName(),
+			QUICConfig: getQuicConfig(&quic.Config{
+				MaxIdleTimeout:  10 * time.Second,
+				EnableDatagrams: true,
+			}),
+			EnableDatagrams:    true,
+			AdditionalSettings: map[uint64]uint64{1337: 42},
+		}
+		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("https://localhost:%d/settings", port), nil)
+		Expect(err).ToNot(HaveOccurred())
+
+		_, err = rt.RoundTrip(req)
+		Expect(err).ToNot(HaveOccurred())
+		var settings *http3.Settings
+		Expect(settingsChan).To(Receive(&settings))
+		Expect(settings).ToNot(BeNil())
+		Expect(settings.EnableDatagram).To(BeTrue())
+		Expect(settings.EnableExtendedConnect).To(BeFalse())
+		Expect(settings.Other).To(HaveKeyWithValue(uint64(1337), uint64(42)))
+	})
+
+	It("processes 1xx response", func() {
+		header1 := "</style.css>; rel=preload; as=style"
+		header2 := "</script.js>; rel=preload; as=script"
+		data := "1xx-test-data"
+		mux.HandleFunc("/103-early-data", func(w http.ResponseWriter, r *http.Request) {
+			defer GinkgoRecover()
+			w.Header().Add("Link", header1)
+			w.Header().Add("Link", header2)
+			w.WriteHeader(http.StatusEarlyHints)
+			n, err := w.Write([]byte(data))
+			Expect(err).NotTo(HaveOccurred())
+			Expect(n).To(Equal(len(data)))
+			w.WriteHeader(http.StatusOK)
+		})
+
+		var (
+			cnt    int
+			status int
+			hdr    textproto.MIMEHeader
+		)
+		ctx := httptrace.WithClientTrace(context.Background(), &httptrace.ClientTrace{
+			Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+				hdr = header
+				status = code
+				cnt++
+				return nil
+			},
+		})
+
+		req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://localhost:%d/103-early-data", port), nil)
+		Expect(err).ToNot(HaveOccurred())
+		resp, err := client.Do(req)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(resp.StatusCode).To(Equal(http.StatusOK))
+		body, err := io.ReadAll(resp.Body)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(string(body)).To(Equal(data))
+		Expect(status).To(Equal(http.StatusEarlyHints))
+		Expect(hdr).To(HaveKeyWithValue("Link", []string{header1, header2}))
+		Expect(cnt).To(Equal(1))
+		Expect(resp.Header).To(HaveKeyWithValue("Link", []string{header1, header2}))
+		Expect(resp.Body.Close()).To(Succeed())
+	})
+
+	It("processes 1xx terminal response", func() {
+		mux.HandleFunc("/101-switch-protocols", func(w http.ResponseWriter, r *http.Request) {
+			defer GinkgoRecover()
+			w.Header().Add("Connection", "upgrade")
+			w.Header().Add("Upgrade", "proto")
+			w.WriteHeader(http.StatusSwitchingProtocols)
+		})
+
+		var (
+			cnt    int
+			status int
+		)
+		ctx := httptrace.WithClientTrace(context.Background(), &httptrace.ClientTrace{
+			Got1xxResponse: func(code int, header textproto.MIMEHeader) error {
+				status = code
+				cnt++
+				return nil
+			},
+		})
+
+		req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("https://localhost:%d/101-switch-protocols", port), nil)
+		Expect(err).ToNot(HaveOccurred())
+		resp, err := client.Do(req)
+		Expect(err).ToNot(HaveOccurred())
+		Expect(resp.StatusCode).To(Equal(http.StatusSwitchingProtocols))
+		Expect(resp.Header).To(HaveKeyWithValue("Connection", []string{"upgrade"}))
+		Expect(resp.Header).To(HaveKeyWithValue("Upgrade", []string{"proto"}))
+		Expect(status).To(Equal(0))
+		Expect(cnt).To(Equal(0))
 	})
 
 	Context("0-RTT", func() {
@@ -606,7 +742,6 @@ var _ = Describe("HTTP tests", func() {
 				TLSClientConfig: tlsConf,
 				QUICConfig: getQuicConfig(&quic.Config{
 					MaxIdleTimeout: 10 * time.Second,
-					Allow0RTT:      true,
 				}),
 				DisableCompression: true,
 			}
diff --git a/integrationtests/self/mitm_test.go b/integrationtests/self/mitm_test.go
index 8ed0e96e..715e311b 100644
--- a/integrationtests/self/mitm_test.go
+++ b/integrationtests/self/mitm_test.go
@@ -419,7 +419,7 @@ var _ = Describe("MITM test", func() {
 					}
 					defer close(done)
 					injected = true
-					initialPacket := testutils.ComposeInitialPacket(hdr.DestConnectionID, hdr.SrcConnectionID, hdr.DestConnectionID, nil, protocol.PerspectiveServer, hdr.Version)
+					initialPacket := testutils.ComposeInitialPacket(hdr.DestConnectionID, hdr.SrcConnectionID, hdr.DestConnectionID, nil, nil, protocol.PerspectiveServer, hdr.Version)
 					_, err = serverTransport.WriteTo(initialPacket, clientTransport.Conn.LocalAddr())
 					Expect(err).ToNot(HaveOccurred())
 				}
@@ -451,7 +451,7 @@ var _ = Describe("MITM test", func() {
 					injected = true
 					// Fake Initial with ACK for packet 2 (unsent)
 					ack := &wire.AckFrame{AckRanges: []wire.AckRange{{Smallest: 2, Largest: 2}}}
-					initialPacket := testutils.ComposeInitialPacket(hdr.DestConnectionID, hdr.SrcConnectionID, hdr.DestConnectionID, []wire.Frame{ack}, protocol.PerspectiveServer, hdr.Version)
+					initialPacket := testutils.ComposeInitialPacket(hdr.DestConnectionID, hdr.SrcConnectionID, hdr.DestConnectionID, nil, []wire.Frame{ack}, protocol.PerspectiveServer, hdr.Version)
 					_, err = serverTransport.WriteTo(initialPacket, clientTransport.Conn.LocalAddr())
 					Expect(err).ToNot(HaveOccurred())
 				}
diff --git a/integrationtests/self/multiplex_test.go b/integrationtests/self/multiplex_test.go
index 7b202c71..0641f8b6 100644
--- a/integrationtests/self/multiplex_test.go
+++ b/integrationtests/self/multiplex_test.go
@@ -249,6 +249,7 @@ var _ = Describe("Multiplexing", func() {
 			defer GinkgoRecover()
 			ticker := time.NewTicker(time.Millisecond / 10)
 			defer ticker.Stop()
+			var wroteFirstPacket bool
 			for {
 				select {
 				case <-ticker.C:
@@ -258,11 +259,18 @@ var _ = Describe("Multiplexing", func() {
 				b := make([]byte, packetLen)
 				rand.Read(b[1:]) // keep the first byte set to 0, so it's not classified as a QUIC packet
 				_, err := tr1.WriteTo(b, tr2.Conn.LocalAddr())
+				// The first sendmsg call on a new UDP socket sometimes errors on Linux.
+				// It's not clear why this happens.
+				// See https://github.com/golang/go/issues/63322.
+				if err != nil && !wroteFirstPacket && isPermissionError(err) {
+					_, err = tr1.WriteTo(b, tr2.Conn.LocalAddr())
+				}
 				if ctx.Err() != nil { // ctx canceled while Read was executing
 					return
 				}
 				Expect(err).ToNot(HaveOccurred())
 				sentPackets.Add(1)
+				wroteFirstPacket = true
 			}
 		}()
 
diff --git a/integrationtests/self/self_suite_linux_test.go b/integrationtests/self/self_suite_linux_test.go
new file mode 100644
index 00000000..7baa1f48
--- /dev/null
+++ b/integrationtests/self/self_suite_linux_test.go
@@ -0,0 +1,21 @@
+//go:build linux
+
+package self_test
+
+import (
+	"errors"
+	"os"
+
+	"golang.org/x/sys/unix"
+)
+
+// The first sendmsg call on a new UDP socket sometimes errors on Linux.
+// It's not clear why this happens.
+// See https://github.com/golang/go/issues/63322.
+func isPermissionError(err error) bool {
+	var serr *os.SyscallError
+	if errors.As(err, &serr) {
+		return serr.Syscall == "sendmsg" && serr.Err == unix.EPERM
+	}
+	return false
+}
diff --git a/integrationtests/self/self_suite_others_test.go b/integrationtests/self/self_suite_others_test.go
new file mode 100644
index 00000000..c0080d35
--- /dev/null
+++ b/integrationtests/self/self_suite_others_test.go
@@ -0,0 +1,7 @@
+//go:build !linux
+
+package self_test
+
+func isPermissionError(err error) bool {
+	return false
+}
diff --git a/send_stream_test.go b/send_stream_test.go
index 50713308..1f9308ba 100644
--- a/send_stream_test.go
+++ b/send_stream_test.go
@@ -35,7 +35,7 @@ var _ = Describe("Send Stream", func() {
 	BeforeEach(func() {
 		mockSender = NewMockStreamSender(mockCtrl)
 		mockFC = mocks.NewMockStreamFlowController(mockCtrl)
-		str = newSendStream(streamID, mockSender, mockFC)
+		str = newSendStream(context.Background(), streamID, mockSender, mockFC)
 
 		timeout := scaleDuration(250 * time.Millisecond)
 		strWithTimeout = gbytes.TimeoutWriter(str, timeout)
diff --git a/server_test.go b/server_test.go
index c86b9ab9..8012f304 100644
--- a/server_test.go
+++ b/server_test.go
@@ -301,7 +301,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -503,7 +503,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -571,7 +571,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -626,7 +626,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -675,7 +675,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -736,7 +736,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -969,7 +969,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -1036,7 +1036,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -1106,7 +1106,7 @@ var _ = Describe("Server", func() {
 					_ *handshake.TokenGenerator,
 					_ bool,
 					_ *logging.ConnectionTracer,
-					_ uint64,
+					_ ConnectionTracingID,
 					_ utils.Logger,
 					_ protocol.Version,
 				) quicConn {
@@ -1176,7 +1176,7 @@ var _ = Describe("Server", func() {
 				_ *handshake.TokenGenerator,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
@@ -1201,7 +1201,7 @@ var _ = Describe("Server", func() {
 		It("rejects new connection attempts if the accept queue is full", func() {
 			connChan := make(chan *MockQUICConn, 1)
 			var wg sync.WaitGroup // to make sure the test fully completes
-			wg.Add(protocol.MaxAcceptQueueSize + 1)
+			wg.Add(protocol.MaxAcceptQueueSize)
 			serv.baseServer.newConn = func(
 				_ sendConn,
 				runner connRunner,
@@ -1217,16 +1217,15 @@ var _ = Describe("Server", func() {
 				_ *handshake.TokenGenerator,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
-				defer wg.Done()
 				ready := make(chan struct{})
 				close(ready)
 				conn := <-connChan
 				conn.EXPECT().handlePacket(gomock.Any())
-				conn.EXPECT().run()
+				conn.EXPECT().run().Do(func() error { wg.Done(); return nil })
 				conn.EXPECT().earlyConnReady().Return(ready)
 				conn.EXPECT().Context().Return(context.Background())
 				return conn
@@ -1242,14 +1241,19 @@ var _ = Describe("Server", func() {
 			}
 
 			Eventually(serv.baseServer.connQueue).Should(HaveLen(protocol.MaxAcceptQueueSize))
+			wg.Wait()
+			wg.Add(1)
 
+			rejected := make(chan struct{})
 			phm.EXPECT().GetStatelessResetToken(gomock.Any())
 			phm.EXPECT().AddWithConnID(gomock.Any(), gomock.Any(), gomock.Any()).Return(true)
 			conn := NewMockQUICConn(mockCtrl)
-			conn.EXPECT().closeWithTransportError(ConnectionRefused)
+			conn.EXPECT().closeWithTransportError(ConnectionRefused).Do(func(qerr.TransportErrorCode) {
+				close(rejected)
+			})
 			connChan <- conn
 			serv.baseServer.handlePacket(getInitialWithRandomDestConnID())
-			wg.Wait()
+			Eventually(rejected).Should(BeClosed())
 		})
 
 		It("doesn't accept new connections if they were closed in the mean time", func() {
@@ -1272,7 +1276,7 @@ var _ = Describe("Server", func() {
 				_ *handshake.TokenGenerator,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
@@ -1394,7 +1398,7 @@ var _ = Describe("Server", func() {
 				_ *handshake.TokenGenerator,
 				_ bool,
 				_ *logging.ConnectionTracer,
-				_ uint64,
+				_ ConnectionTracingID,
 				_ utils.Logger,
 				_ protocol.Version,
 			) quicConn {
diff --git a/stream_test.go b/stream_test.go
index e1e3804f..5d0e64b5 100644
--- a/stream_test.go
+++ b/stream_test.go
@@ -1,6 +1,7 @@
 package quic
 
 import (
+	"context"
 	"errors"
 	"io"
 	"os"
@@ -41,7 +42,7 @@ var _ = Describe("Stream", func() {
 	BeforeEach(func() {
 		mockSender = NewMockStreamSender(mockCtrl)
 		mockFC = mocks.NewMockStreamFlowController(mockCtrl)
-		str = newStream(streamID, mockSender, mockFC)
+		str = newStream(context.Background(), streamID, mockSender, mockFC)
 
 		timeout := scaleDuration(250 * time.Millisecond)
 		strWithTimeout = struct {
diff --git a/streams_map_test.go b/streams_map_test.go
index 77ee4aa8..b4a2d91f 100644
--- a/streams_map_test.go
+++ b/streams_map_test.go
@@ -87,7 +87,7 @@ var _ = Describe("Streams Map", func() {
 
 			BeforeEach(func() {
 				mockSender = NewMockStreamSender(mockCtrl)
-				m = newStreamsMap(mockSender, newFlowController, MaxBidiStreamNum, MaxUniStreamNum, perspective).(*streamsMap)
+				m = newStreamsMap(context.Background(), mockSender, newFlowController, MaxBidiStreamNum, MaxUniStreamNum, perspective).(*streamsMap)
 			})
 
 			Context("opening", func() {
diff --git a/testutils/testutils.go b/testutils/testutils.go
index dd602d2d..efeb9045 100644
--- a/testutils/testutils.go
+++ b/testutils/testutils.go
@@ -37,6 +37,7 @@ func packRawPayload(version protocol.Version, frames []wire.Frame) []byte {
 // containing specified frames.
 func ComposeInitialPacket(
 	srcConnID, destConnID, key protocol.ConnectionID,
+	token []byte,
 	frames []wire.Frame,
 	sentBy protocol.Perspective,
 	version protocol.Version,
@@ -58,6 +59,7 @@ func ComposeInitialPacket(
 	hdr := &wire.ExtendedHeader{
 		Header: wire.Header{
 			Type:             protocol.PacketTypeInitial,
+			Token:            token,
 			SrcConnectionID:  srcConnID,
 			DestConnectionID: destConnID,
 			Length:           protocol.ByteCount(length),