Repository: quic-go/quic-go
Pull request #4409 (2024-04-03 20:26:54)

http3: expose an OpenStream method on the RoundTripper
Part of #3522.

The stream exposes two methods required for doing an HTTP request: `SendRequestHeader` and `ReadResponse`. This can be used by applications that wish to use the stream for non-HTTP content afterwards. This will lead to a simplification in the API we need to expose for WebTransport, and will make it easier to send HTTP Datagrams associated with this stream.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) Report
Attention: Patch coverage is `69.79866%` with `45 lines` in your changes are missing coverage. Please review.
> Project coverage is 84.82%. Comparing base [(`183d42a`)](https://app.codecov.io/gh/quic-go/quic-go/commit/183d42a729adb8b53a57755989e07982f403c9fa?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) to head [(`a2bbf75`)](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go).

| [Files](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) | Patch % | Lines |
|---|---|---|
| [http3/http\_stream.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&filepath=http3%2Fhttp_stream.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aHR0cDMvaHR0cF9zdHJlYW0uZ28=) | 76.34% | [17 Missing and 5 partials :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [http3/client.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&filepath=http3%2Fclient.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aHR0cDMvY2xpZW50Lmdv) | 62.16% | [12 Missing and 2 partials :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [http3/roundtrip.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&filepath=http3%2Froundtrip.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aHR0cDMvcm91bmR0cmlwLmdv) | 12.50% | [7 Missing :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go) |
| [http3/server.go](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&filepath=http3%2Fserver.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=quic-go#diff-aHR0cDMvc2VydmVyLmdv) | 60.00% | [1 Missing and 1 partial :warning: ](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?src=pr&el=tree&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    #4409      +/-   ##
==========================================
- Coverage   84.92%   84.82%   -0.10%     
==========================================
  Files         152      152              
  Lines       14357    14392      +35     
==========================================
+ Hits        12192    12207      +15     
- Misses       1666     1688      +22     
+ Partials      499      497       -2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/quic-go/quic-go/pull/4409?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/http3/body.go b/http3/body.go
index 6beb2037..84410be4 100644
--- a/http3/body.go
+++ b/http3/body.go
@@ -107,7 +107,7 @@ func (r *requestBody) Settings(ctx context.Context) (*Settings, error) {
 }
 
 type hijackableBody struct {
-	body
+	body body
 	conn quic.Connection // only needed to implement Hijacker
 
 	// only set for the http.Response
@@ -120,7 +120,6 @@ type hijackableBody struct {
 var (
 	_ io.ReadCloser = &hijackableBody{}
 	_ Hijacker      = &hijackableBody{}
-	_ HTTPStreamer  = &hijackableBody{}
 )
 
 func newResponseBody(str Stream, conn quic.Connection, done chan<- struct{}) *hijackableBody {
@@ -132,7 +131,7 @@ func newResponseBody(str Stream, conn quic.Connection, done chan<- struct{}) *hi
 }
 
 func (r *hijackableBody) Read(b []byte) (int, error) {
-	n, err := r.str.Read(b)
+	n, err := r.body.str.Read(b)
 	if err != nil {
 		r.requestDone()
 	}
@@ -152,9 +151,8 @@ func (r *hijackableBody) requestDone() {
 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 }
 func (r *hijackableBody) StreamCreator() StreamCreator { return r.conn }
diff --git a/http3/client.go b/http3/client.go
index 1a0a891e..05d769ac 100644
--- a/http3/client.go
+++ b/http3/client.go
@@ -8,7 +8,6 @@ import (
 	"io"
 	"net"
 	"net/http"
-	"strconv"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -114,7 +113,7 @@ func newClient(hostname string, tlsConf *tls.Config, opts *roundTripperOpts, con
 	tlsConf.NextProtos = []string{versionToALPN(conf.Versions[0])}
 
 	return &client{
-		hostname:      authorityAddr("https", hostname),
+		hostname:      authorityAddr(hostname),
 		tlsConf:       tlsConf,
 		requestWriter: newRequestWriter(logger),
 		decoder:       qpack.NewDecoder(func(hf qpack.HeaderField) {}),
@@ -222,16 +221,20 @@ func (c *client) RoundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Respon
 	return rsp, err
 }
 
+func (c *client) dialConn(ctx context.Context) error {
+	c.dialOnce.Do(func() {
+		c.handshakeErr = c.dial(ctx)
+	})
+	return c.handshakeErr
+}
+
 func (c *client) roundTripOpt(req *http.Request, opt RoundTripOpt) (*http.Response, error) {
-	if authorityAddr("https", hostnameFromRequest(req)) != c.hostname {
+	if authorityAddr(hostnameFromURL(req.URL)) != 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
+	if err := c.dialConn(req.Context()); err != nil {
+		return nil, err
 	}
 
 	// At this point, c.conn is guaranteed to be set.
@@ -290,31 +293,34 @@ 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, conn, 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 *client) OpenStream(ctx context.Context) (RequestStream, error) {
+	if err := c.dialConn(ctx); err != nil {
+		return nil, err
 	}
-	return rsp, maybeReplaceError(rerr.err)
+	conn := *c.conn.Load()
+	str, err := conn.OpenStreamSync(ctx)
+	if err != nil {
+		return nil, err
+	}
+	return newRequestStream(
+		newStream(str, func(e ErrCode) { conn.CloseWithError(quic.ApplicationErrorCode(e), "") }),
+		c.hconn,
+		c.requestWriter,
+		nil,
+		c.decoder,
+		c.opts.DisableCompression,
+		c.maxHeaderBytes(),
+		func(e ErrCode) { conn.CloseWithError(quic.ApplicationErrorCode(e), "") },
+	), nil
 }
 
 // cancelingReader reads from the io.Reader.
@@ -356,21 +362,23 @@ 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 *client) doRequest(req *http.Request, conn quic.EarlyConnection, str quic.Stream, reqDone chan<- struct{}) (*http.Response, error) {
+	hstr := newRequestStream(
+		newStream(str, func(e ErrCode) { conn.CloseWithError(quic.ApplicationErrorCode(e), "") }),
+		c.hconn,
+		c.requestWriter,
+		reqDone,
+		c.decoder,
+		c.opts.DisableCompression,
+		c.maxHeaderBytes(),
+		func(e ErrCode) { conn.CloseWithError(quic.ApplicationErrorCode(e), "") },
+	)
+	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)
@@ -382,75 +390,18 @@ func (c *client) doRequest(req *http.Request, conn quic.EarlyConnection, str qui
 			if err := c.sendRequestBody(hstr, req.Body, contentLength); err != nil {
 				c.logger.Errorf("Error writing request: %s", err)
 			}
-			if !opt.DontCloseRequestStream {
-				hstr.Close()
-			}
+			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)
-	}
-
-	res, err := responseFromHeaders(hfs)
+	res, err := hstr.ReadResponse()
 	if err != nil {
-		return nil, newStreamError(ErrCodeMessageError, err)
+		return nil, 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
-			}
-		}
-	}
-
-	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{}
+	return res, nil
 }
 
 func (c *client) HandshakeComplete() bool {
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..9c17a742 100644
--- a/http3/http_stream.go
+++ b/http3/http_stream.go
@@ -3,32 +3,52 @@ package http3
 import (
 	"errors"
 	"fmt"
+	"io"
+	"net/http"
+	"strconv"
 
 	"github.com/quic-go/quic-go"
+
+	"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
+	closeConnection func(ErrCode)
 
-	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, closeConnection func(ErrCode)) *stream {
 	return &stream{
-		Stream:       str,
-		onFrameError: onFrameError,
-		buf:          make([]byte, 0, 16),
+		Stream:          str,
+		closeConnection: closeConnection,
+		buf:             make([]byte, 0, 16),
 	}
 }
 
@@ -48,7 +68,7 @@ func (s *stream) Read(b []byte) (int, error) {
 				s.bytesRemainingInFrame = f.Length
 				break parseLoop
 			default:
-				s.onFrameError()
+				s.closeConnection(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,10 +100,150 @@ func (s *stream) Write(b []byte) (int, error) {
 	return s.Stream.Write(b)
 }
 
+// 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
+
+	conn quic.Connection
+
+	responseBody io.ReadCloser // set by ReadResponse
+
+	decoder            *qpack.Decoder
+	requestWriter      *requestWriter
+	closeConnection    func(ErrCode)
+	maxHeaderBytes     uint64
+	reqDone            chan<- struct{}
+	disableCompression bool
+
+	sentRequest   bool
+	requestedGzip bool
+	isConnect     bool
+}
+
+var _ RequestStream = &requestStream{}
+
+func newRequestStream(
+	str *stream,
+	conn quic.Connection,
+	requestWriter *requestWriter,
+	reqDone chan<- struct{},
+	decoder *qpack.Decoder,
+	disableCompression bool,
+	maxHeaderBytes uint64,
+	closeConnection func(ErrCode),
+) *requestStream {
+	return &requestStream{
+		stream:             str,
+		conn:               conn,
+		requestWriter:      requestWriter,
+		reqDone:            reqDone,
+		decoder:            decoder,
+		disableCompression: disableCompression,
+		closeConnection:    closeConnection,
+		maxHeaderBytes:     maxHeaderBytes,
+	}
+}
+
+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 *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
+	}
+	s.isConnect = req.Method == http.MethodConnect
+	s.sentRequest = true
+	return s.requestWriter.WriteRequestHeader(s.Stream, req, s.requestedGzip)
+}
+
+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)
+	}
+	hf, ok := frame.(*headersFrame)
+	if !ok {
+		s.closeConnection(ErrCodeFrameUnexpected)
+		return nil, errors.New("http3: expected first frame to be a HEADERS frame")
+	}
+	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.closeConnection(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.
+	var httpStr Stream
+	if _, ok := res.Header["Content-Length"]; ok && res.ContentLength >= 0 {
+		httpStr = newLengthLimitedStream(s.stream, res.ContentLength)
+	} else {
+		httpStr = s.stream
+	}
+	respBody := newResponseBody(httpStr, s.conn, 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
+}
+
 var errTooMuchData = errors.New("peer sent too much data")
 
 type lengthLimitedStream struct {
 	*stream
+
 	contentLength int64
 	read          int64
 	resetStream   bool
diff --git a/http3/request_writer.go b/http3/request_writer.go
index 875f4031..3f492bd8 100644
--- a/http3/request_writer.go
+++ b/http3/request_writer.go
@@ -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/roundtrip.go b/http3/roundtrip.go
index ecbb6974..d3b4c360 100644
--- a/http3/roundtrip.go
+++ b/http3/roundtrip.go
@@ -8,6 +8,7 @@ import (
 	"io"
 	"net"
 	"net/http"
+	"net/url"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -32,9 +33,6 @@ 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.
@@ -44,6 +42,7 @@ type RoundTripOpt struct {
 type roundTripCloser interface {
 	RoundTripOpt(*http.Request, RoundTripOpt) (*http.Response, error)
 	HandshakeComplete() bool
+	OpenStream(context.Context) (RequestStream, error)
 	io.Closer
 }
 
@@ -154,7 +153,7 @@ 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))
+	hostname := authorityAddr(hostnameFromURL(req.URL))
 	cl, isReused, err := r.getClient(hostname, opt.OnlyCachedConn)
 	if err != nil {
 		return nil, err
@@ -177,6 +176,15 @@ func (r *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
 	return r.RoundTripOpt(req, RoundTripOpt{})
 }
 
+func (r *RoundTripper) OpenStream(ctx context.Context, url *url.URL) (RequestStream, error) {
+	hostname := authorityAddr(hostnameFromURL(url))
+	cl, _, err := r.getClient(hostname, false)
+	if err != nil {
+		return nil, err
+	}
+	return cl.OpenStream(ctx)
+}
+
 func (r *RoundTripper) getClient(hostname string, onlyCached bool) (rtc *roundTripCloserWithCount, isReused bool, err error) {
 	r.mutex.Lock()
 	defer r.mutex.Unlock()
diff --git a/http3/server.go b/http3/server.go
index b1449e81..a6eb9cf9 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -477,8 +477,8 @@ func (s *Server) handleConn(conn quic.Connection) error {
 			return fmt.Errorf("accepting stream failed: %w", err)
 		}
 		go func() {
-			rerr := s.handleRequest(hconn, str, decoder, func() {
-				conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "")
+			rerr := s.handleRequest(hconn, str, decoder, func(e ErrCode) {
+				conn.CloseWithError(quic.ApplicationErrorCode(e), "")
 			})
 			if rerr.err == errHijacked {
 				return
@@ -509,7 +509,7 @@ func (s *Server) maxHeaderBytes() uint64 {
 	return uint64(s.MaxHeaderBytes)
 }
 
-func (s *Server) handleRequest(conn *connection, str quic.Stream, decoder *qpack.Decoder, onFrameError func()) requestError {
+func (s *Server) handleRequest(conn *connection, str quic.Stream, decoder *qpack.Decoder, closeConnection func(ErrCode)) requestError {
 	var ufh unknownFrameHandlerFunc
 	if s.StreamHijacker != nil {
 		ufh = func(ft FrameType, e error) (processed bool, err error) {
@@ -557,9 +557,9 @@ func (s *Server) handleRequest(conn *connection, str quic.Stream, decoder *qpack
 	// See section 4.1.2 of RFC 9114.
 	var httpStr Stream
 	if _, ok := req.Header["Content-Length"]; ok && req.ContentLength >= 0 {
-		httpStr = newLengthLimitedStream(newStream(str, onFrameError), req.ContentLength)
+		httpStr = newLengthLimitedStream(newStream(str, closeConnection), req.ContentLength)
 	} else {
-		httpStr = newStream(str, onFrameError)
+		httpStr = newStream(str, closeConnection)
 	}
 	body := newRequestBody(httpStr, conn.Context(), conn.ReceivedSettings(), conn.Settings)
 	req.Body = body


Test patch:
diff --git a/http3/client_test.go b/http3/client_test.go
index 2a0ef451..259567af 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -6,7 +6,6 @@ import (
 	"context"
 	"crypto/tls"
 	"errors"
-	"fmt"
 	"io"
 	"net/http"
 	"sync"
@@ -25,6 +24,16 @@ 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(rstr, nil, utils.DefaultLogger)
+	rw.WriteHeader(status)
+	rw.Flush()
+	return buf.Bytes()
+}
+
 var _ = Describe("Client", func() {
 	var (
 		cl            *client
@@ -538,16 +547,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)
@@ -600,6 +599,7 @@ 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
 				})
@@ -614,7 +614,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),
@@ -631,22 +631,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
 
@@ -668,15 +652,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"))
+				Expect(err).To(MatchError(testErr))
 				hfs := decodeHeader(strBuf)
 				Expect(hfs).To(HaveKeyWithValue(":method", "POST"))
 				Expect(hfs).To(HaveKeyWithValue(":path", "/upload"))
@@ -686,6 +673,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() {
@@ -708,6 +696,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)
@@ -716,14 +705,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"))
+				Expect(err).To(MatchError(testErr))
 				Eventually(closed).Should(BeClosed())
 			})
 
@@ -735,7 +725,7 @@ var _ = Describe("Client", func() {
 				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"))
+				Expect(err).To(MatchError("http3: expected first frame to be a HEADERS frame"))
 				Eventually(closed).Should(BeClosed())
 			})
 
@@ -748,6 +738,7 @@ 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 })
@@ -760,69 +751,63 @@ var _ = Describe("Client", func() {
 			It("cancels the stream when the HEADERS frame is too large", func() {
 				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)"))
+				Expect(err).To(MatchError("http3: HEADERS frame too large: 1338 bytes (max: 1337)"))
 				Eventually(closed).Should(BeClosed())
 			})
 		})
 
 		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.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().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.RoundTripOpt(req, RoundTripOpt{})
+				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)
@@ -855,11 +840,14 @@ 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"))
+				testErr := errors.New("test done")
+				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
 				_, err := cl.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("test done"))
+				Expect(err).To(MatchError(testErr))
 				hfs := decodeHeader(buf)
 				Expect(hfs).To(HaveKeyWithValue("accept-encoding", "gzip"))
 			})
@@ -872,11 +860,14 @@ 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"))
+				testErr := errors.New("test done")
+				str.EXPECT().Read(gomock.Any()).Return(0, testErr)
 				_, err = client.RoundTripOpt(req, RoundTripOpt{})
-				Expect(err).To(MatchError("test done"))
+				Expect(err).To(MatchError(testErr))
 				hfs := decodeHeader(buf)
 				Expect(hfs).ToNot(HaveKey("accept-encoding"))
 			})
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..e99115f5 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/qpack"
 	"github.com/quic-go/quic-go"
 	mockquic "github.com/quic-go/quic-go/internal/mocks/quic"
+	"github.com/quic-go/quic-go/internal/utils"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
@@ -26,7 +30,7 @@ var _ = Describe("Stream", func() {
 			errorCbCalled bool
 		)
 
-		errorCb := func() { errorCbCalled = true }
+		errorCb := func(ErrCode) { errorCbCalled = true }
 
 		BeforeEach(func() {
 			buf = &bytes.Buffer{}
@@ -163,7 +167,7 @@ var _ = Describe("length-limited streams", func() {
 		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") })
+		str = newStream(qstr, func(ErrCode) { Fail("didn't expect error callback to be called") })
 	})
 
 	It("reads all frames", func() {
@@ -201,3 +205,56 @@ var _ = Describe("length-limited streams", func() {
 		Expect(data).To(Equal([]byte("foo")))
 	})
 })
+
+var _ = Describe("Request Stream", func() {
+	var str *requestStream
+	var qstr *mockquic.MockStream
+
+	BeforeEach(func() {
+		qstr = mockquic.NewMockStream(mockCtrl)
+		requestWriter := newRequestWriter(utils.DefaultLogger)
+		str = newRequestStream(
+			newStream(qstr, func(code ErrCode) { Fail("errored") }),
+			mockquic.NewMockEarlyConnection(mockCtrl),
+			requestWriter,
+			make(chan struct{}),
+			qpack.NewDecoder(func(qpack.HeaderField) {}),
+			true,
+			math.MaxUint64,
+			func(code ErrCode) { Fail("errored") },
+		)
+	})
+
+	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("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("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
index adc0c687..26c14899 100644
--- a/http3/mock_roundtripcloser_test.go
+++ b/http3/mock_roundtripcloser_test.go
@@ -10,6 +10,7 @@
 package http3
 
 import (
+	context "context"
 	http "net/http"
 	reflect "reflect"
 
@@ -115,6 +116,45 @@ func (c *MockRoundTripCloserHandshakeCompleteCall) DoAndReturn(f func() bool) *M
 	return c
 }
 
+// OpenStream mocks base method.
+func (m *MockRoundTripCloser) OpenStream(arg0 context.Context) (RequestStream, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "OpenStream", arg0)
+	ret0, _ := ret[0].(RequestStream)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// OpenStream indicates an expected call of OpenStream.
+func (mr *MockRoundTripCloserMockRecorder) OpenStream(arg0 any) *MockRoundTripCloserOpenStreamCall {
+	mr.mock.ctrl.T.Helper()
+	call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenStream", reflect.TypeOf((*MockRoundTripCloser)(nil).OpenStream), arg0)
+	return &MockRoundTripCloserOpenStreamCall{Call: call}
+}
+
+// MockRoundTripCloserOpenStreamCall wrap *gomock.Call
+type MockRoundTripCloserOpenStreamCall struct {
+	*gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockRoundTripCloserOpenStreamCall) Return(arg0 RequestStream, arg1 error) *MockRoundTripCloserOpenStreamCall {
+	c.Call = c.Call.Return(arg0, arg1)
+	return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockRoundTripCloserOpenStreamCall) Do(f func(context.Context) (RequestStream, error)) *MockRoundTripCloserOpenStreamCall {
+	c.Call = c.Call.Do(f)
+	return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockRoundTripCloserOpenStreamCall) DoAndReturn(f func(context.Context) (RequestStream, error)) *MockRoundTripCloserOpenStreamCall {
+	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()
diff --git a/integrationtests/self/http_test.go b/integrationtests/self/http_test.go
index c8b1d282..25304d57 100644
--- a/integrationtests/self/http_test.go
+++ b/integrationtests/self/http_test.go
@@ -431,11 +431,13 @@ 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})
+		str, err := client.Transport.(*http3.RoundTripper).OpenStream(context.Background(), req.URL)
+		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())