Repository: quic-go/quic-go
Pull request #4581 (2024-07-05 18:45:01)

http3: add client-side HTTP Trailer support
This is a continuation of the work done in #2344 and will resolve _half_ of #2266. This adds support for reading trailers in the HTTP client sent from servers after the HTTP body is received. I've adapted these changes to work with the updated state of the repo.

I've tested with this with a ConnectRPC client configured with quic-go with a gRPC server and it appears to work well.

google-cla[bot]: Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this [failed invocation](https://github.com/quic-go/quic-go/pull/4581/checks?check_run_id=27096468157) of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.
akshayjshah: After quite a few years, I'm excited to see some progress on trailers support!
sudorandom: @marten-seemann is it possible to get some attention on this PR at some point? At the very least, can you allow the CI workflows to run?
sudorandom: @marten-seemann I have a branch with server support for trailers that contains an integration test that tests both client and server behavior. It's also probably not 100% perfect. Would you prefer that I make a new PR now, wait for this PR to be merged or include it in this PR?
marten-seemann: > @marten-seemann I have a branch with server support for trailers that contains an integration test that tests both client and server behavior. It's also probably not 100% perfect. Would you prefer that I make a new PR now, wait for this PR to be merged or include it in this PR?

A separate PR would be great!
sudorandom: @marten-seemann I cherrypicked your changes and finished the work needed to make it fully work. I think it looks pretty good now.

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index 194e51fa..00000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,42 +0,0 @@
-version: 2.1
-executors:
-  test-go122:
-    docker:
-      - image: "cimg/go:1.22"
-    environment:
-      runrace: true
-      TIMESCALE_FACTOR: 3
- 
-jobs:
-  "test": &test
-    executor: test-go122
-    steps:
-      - checkout
-      - run:
-          name: "Build infos"
-          command: go version
-      - run:
-          name: "Run tools tests"
-          command: go run github.com/onsi/ginkgo/v2/ginkgo -race -r -v -randomize-all -trace integrationtests/tools
-      - run:
-          name: "Run self integration tests"
-          command: go run github.com/onsi/ginkgo/v2/ginkgo -v -randomize-all -trace integrationtests/self
-      - run:
-          name: "Run version negotiation tests"
-          command: go run github.com/onsi/ginkgo/v2/ginkgo -v -randomize-all -trace integrationtests/versionnegotiation
-      - run:
-          name: "Run self integration tests with race detector"
-          command: go run github.com/onsi/ginkgo/v2/ginkgo -race -v -randomize-all -trace integrationtests/self
-      - run:
-          name: "Run self integration tests with qlog"
-          command: go run github.com/onsi/ginkgo/v2/ginkgo -v -randomize-all -trace integrationtests/self -- -qlog
-      - run:
-          name: "Run version negotiation tests with qlog"
-          command: go run github.com/onsi/ginkgo/v2/ginkgo -v -randomize-all -trace integrationtests/versionnegotiation -- -qlog
-  go122:
-    <<: *test
-
-workflows:
-  workflow:
-    jobs:
-      - go122
diff --git a/.clusterfuzzlite/Dockerfile b/.clusterfuzzlite/Dockerfile
index d9fedb9a..147762f0 100644
--- a/.clusterfuzzlite/Dockerfile
+++ b/.clusterfuzzlite/Dockerfile
@@ -3,7 +3,7 @@ FROM gcr.io/oss-fuzz-base/base-builder-go:v1
 ARG TARGETPLATFORM
 RUN echo "TARGETPLATFORM: ${TARGETPLATFORM}"
 
-ENV GOVERSION=1.22.0
+ENV GOVERSION=1.23.0
 
 RUN platform=$(echo ${TARGETPLATFORM} | tr '/' '-') && \
   filename="go${GOVERSION}.${platform}.tar.gz" && \
diff --git a/.github/workflows/cross-compile.yml b/.github/workflows/cross-compile.yml
index e72e1227..6bbd176f 100644
--- a/.github/workflows/cross-compile.yml
+++ b/.github/workflows/cross-compile.yml
@@ -4,7 +4,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        go: [ "1.21.x", "1.22.x" ]
+        go: [ "1.22.x", "1.23.x" ]
     runs-on: ${{ fromJSON(vars['CROSS_COMPILE_RUNNER_UBUNTU'] || '"ubuntu-latest"') }}
     name: "Cross Compilation (Go ${{matrix.go}})"
     timeout-minutes: 30
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index ae521531..b5d855e4 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -6,12 +6,18 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "ubuntu" ]
-        go: [ "1.21.x", "1.22.x", "1.23.0-rc.2" ]
+        go: [ "1.22.x", "1.23.x" ]
+        race: [ false ]
         include:
+          - os: "ubuntu"
+            go: "1.23.x"
+            race: true
           - os: "windows"
-            go: "1.21.x"
+            go: "1.23.x"
+            race: false
           - os: "macos"
-            go: "1.21.x"
+            go: "1.23.x"
+            race: false
     runs-on: ${{ fromJSON(vars[format('INTEGRATION_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }}
     timeout-minutes: 30
     defaults:
@@ -20,7 +26,7 @@ jobs:
     env:
       DEBUG: false # set this to true to export qlogs and save them as artifacts
       TIMESCALE_FACTOR: 3
-    name: Integration Tests (${{ matrix.os }}, Go ${{ matrix.go }})
+    name: "Integration (${{ matrix.os }}, Go ${{ matrix.go }}, race: ${{ matrix.race }})"
     steps:
       - uses: actions/checkout@v4
       - uses: actions/setup-go@v5
@@ -30,28 +36,31 @@ jobs:
       - name: set qlogger
         if: env.DEBUG == 'true'
         run: echo "QLOGFLAG= -qlog" >> $GITHUB_ENV
+      - name: Enable race detector
+        if: ${{ matrix.race }}
+        run: echo "RACEFLAG= -race" >> $GITHUB_ENV
       - name: Run other tests
         run: |
-          go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace -skip-package self,versionnegotiation integrationtests
-          go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/versionnegotiation -- ${{ env.QLOGFLAG }}
+          go run github.com/onsi/ginkgo/v2/ginkgo ${{ env.RACEFLAG }} -r -v -randomize-all -randomize-suites -trace -skip-package self,versionnegotiation integrationtests
+          go run github.com/onsi/ginkgo/v2/ginkgo ${{ env.RACEFLAG }} -r -v -randomize-all -randomize-suites -trace integrationtests/versionnegotiation -- ${{ env.QLOGFLAG }}
       - name: Run self tests, using QUIC v1
         if: success() || failure() # run this step even if the previous one failed
-        run: go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
+        run: go run github.com/onsi/ginkgo/v2/ginkgo ${{ env.RACEFLAG }} -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
       - name: Run self tests, using QUIC v2
-        if: success() || failure() # run this step even if the previous one failed
-        run: go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=2 ${{ env.QLOGFLAG }}
+        if: ${{ !matrix.race && (success() || failure()) }} # run this step even if the previous one failed
+        run: go run  github.com/onsi/ginkgo/v2/ginkgo ${{ env.RACEFLAG }} -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=2 ${{ env.QLOGFLAG }}
       - name: Run self tests, with GSO disabled
         if: ${{ matrix.os == 'ubuntu' && (success() || failure()) }} # run this step even if the previous one failed
         env:
           QUIC_GO_DISABLE_GSO: true
-        run: go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
+        run: go run github.com/onsi/ginkgo/v2/ginkgo ${{ env.RACEFLAG }} -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
       - name: Run self tests, with ECN disabled
-        if: ${{ matrix.os == 'ubuntu' && (success() || failure()) }} # run this step even if the previous one failed
+        if: ${{ !matrix.race && matrix.os == 'ubuntu' && (success() || failure()) }} # run this step even if the previous one failed
         env:
           QUIC_GO_DISABLE_ECN: true
-        run: go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
+        run: go run github.com/onsi/ginkgo/v2/ginkgo ${{ env.RACEFLAG }} -r -v -randomize-all -randomize-suites -trace integrationtests/self -- -version=1 ${{ env.QLOGFLAG }}
       - name: Run tests (32 bit)
-        if: ${{ matrix.os != 'macos' && (success() || failure()) }} # run this step even if the previous one failed
+        if: ${{ !matrix.race && matrix.os != 'macos' && (success() || failure()) }} # run this step even if the previous one failed
         env:
           GOARCH: 386
         run: |
@@ -59,11 +68,12 @@ jobs:
           go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/versionnegotiation -- ${{ env.QLOGFLAG }}
           go run github.com/onsi/ginkgo/v2/ginkgo -r -v -randomize-all -randomize-suites -trace integrationtests/self -- ${{ env.QLOGFLAG }}
       - name: Run benchmarks
+        if: ${{ !matrix.race }}
         run: go test -v -run=^$ -bench=. ./integrationtests/self
       - name: save qlogs
         if: ${{ always() && env.DEBUG == 'true' }}
         uses: actions/upload-artifact@v4
         with:
-          name: qlogs-${{ matrix.os }}-go${{ matrix.go }}
+          name: qlogs-${{ matrix.os }}-go${{ matrix.go }}-race${{ matrix.race }}
           path: integrationtests/self/*.qlog
           retention-days: 7
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 2b39852f..a193943d 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -8,7 +8,7 @@ jobs:
       - uses: actions/checkout@v4
       - uses: actions/setup-go@v5
         with:
-          go-version: "1.22.x"
+          go-version: "1.23.x"
       - name: Check that no non-test files import Ginkgo or Gomega
         run: .github/workflows/no_ginkgo.sh
       - name: Check for //go:build ignore in .go files
@@ -39,9 +39,9 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        go: [  "1.21.x", "1.22.x" ]
+        go: [  "1.22.x", "1.23.x" ]
     env:
-      GOLANGCI_LINT_VERSION: v1.58.0
+      GOLANGCI_LINT_VERSION: v1.60.1
     name: golangci-lint (Go ${{ matrix.go }})
     steps:
       - uses: actions/checkout@v4
diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml
index 2a45d5e7..c654c06e 100644
--- a/.github/workflows/unit.yml
+++ b/.github/workflows/unit.yml
@@ -7,7 +7,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ "ubuntu", "windows", "macos" ]
-        go: [ "1.21.x", "1.22.x", "1.23.0-rc.2" ]
+        go: [ "1.22.x", "1.23.x" ]
     runs-on: ${{ fromJSON(vars[format('UNIT_RUNNER_{0}', matrix.os)] || format('"{0}-latest"', matrix.os)) }}
     name: Unit tests (${{ matrix.os}}, Go ${{ matrix.go }})
     timeout-minutes: 30
diff --git a/go.mod b/go.mod
index 8cc39ebd..031290e6 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
 module github.com/quic-go/quic-go
 
-go 1.21
+go 1.22
 
 require (
 	github.com/francoispqt/gojay v1.2.13
diff --git a/http3/conn.go b/http3/conn.go
index 0fd9412f..5c12b704 100644
--- a/http3/conn.go
+++ b/http3/conn.go
@@ -3,8 +3,10 @@ package http3
 import (
 	"context"
 	"fmt"
+	"io"
 	"log/slog"
 	"net"
+	"net/http"
 	"sync"
 	"sync/atomic"
 	"time"
@@ -112,8 +114,36 @@ func (c *connection) openRequestStream(
 	c.streams[str.StreamID()] = datagrams
 	c.streamMx.Unlock()
 	qstr := newStateTrackingStream(str, c, datagrams)
-	hstr := newStream(qstr, c, datagrams)
-	return newRequestStream(hstr, requestWriter, reqDone, c.decoder, disableCompression, maxHeaderBytes), nil
+	rsp := &http.Response{}
+	hstr := newStream(qstr, c, datagrams, func(r io.Reader, l uint64) error {
+		hdr, err := c.parseTrailer(r, l, maxHeaderBytes)
+		if err != nil {
+			return err
+		}
+		rsp.Trailer = hdr
+		return nil
+	})
+	return newRequestStream(hstr, requestWriter, reqDone, c.decoder, disableCompression, maxHeaderBytes, rsp), nil
+}
+
+func (c *connection) parseTrailer(r io.Reader, l, maxHeaderBytes uint64) (http.Header, error) {
+	if l > maxHeaderBytes {
+		return nil, fmt.Errorf("HEADERS frame too large: %d bytes (max: %d)", l, maxHeaderBytes)
+	}
+
+	b := make([]byte, l)
+	if _, err := io.ReadFull(r, b); err != nil {
+		return nil, err
+	}
+	fields, err := c.decoder.DecodeFull(b)
+	if err != nil {
+		return nil, err
+	}
+	h := http.Header{}
+	for _, field := range fields {
+		h.Add(field.Name, field.Value)
+	}
+	return h, nil
 }
 
 func (c *connection) acceptStream(ctx context.Context) (quic.Stream, *datagrammer, error) {
diff --git a/http3/headers.go b/http3/headers.go
index d587efd4..fd50d04a 100644
--- a/http3/headers.go
+++ b/http3/headers.go
@@ -178,25 +178,28 @@ func hostnameFromURL(url *url.URL) string {
 	return ""
 }
 
-func responseFromHeaders(headerFields []qpack.HeaderField) (*http.Response, error) {
+// updateResponseFromHeaders sets up http.Response as an HTTP/3 response,
+// using the decoded qpack header filed.
+// It is only called for the HTTP header (and not the HTTP trailer).
+// It takes an http.Response as an argument to allow the caller to set the trailer later on.
+func updateResponseFromHeaders(rsp *http.Response, headerFields []qpack.HeaderField) error {
 	hdr, err := parseHeaders(headerFields, false)
 	if err != nil {
-		return nil, err
+		return err
 	}
 	if hdr.Status == "" {
-		return nil, errors.New("missing status field")
-	}
-	rsp := &http.Response{
-		Proto:         "HTTP/3.0",
-		ProtoMajor:    3,
-		Header:        hdr.Headers,
-		ContentLength: hdr.ContentLength,
+		return errors.New("missing status field")
 	}
+	rsp.Proto = "HTTP/3.0"
+	rsp.ProtoMajor = 3
+	rsp.Header = hdr.Headers
+	rsp.ContentLength = hdr.ContentLength
+
 	status, err := strconv.Atoi(hdr.Status)
 	if err != nil {
-		return nil, fmt.Errorf("invalid status code: %w", err)
+		return fmt.Errorf("invalid status code: %w", err)
 	}
 	rsp.StatusCode = status
 	rsp.Status = hdr.Status + " " + http.StatusText(status)
-	return rsp, nil
+	return nil
 }
diff --git a/http3/http_stream.go b/http3/http_stream.go
index 63bea0e1..dd715064 100644
--- a/http3/http_stream.go
+++ b/http3/http_stream.go
@@ -49,16 +49,20 @@ type stream struct {
 	bytesRemainingInFrame uint64
 
 	datagrams *datagrammer
+
+	parseTrailer  func(io.Reader, uint64) error
+	parsedTrailer bool
 }
 
 var _ Stream = &stream{}
 
-func newStream(str quic.Stream, conn *connection, datagrams *datagrammer) *stream {
+func newStream(str quic.Stream, conn *connection, datagrams *datagrammer, parseTrailer func(io.Reader, uint64) error) *stream {
 	return &stream{
-		Stream:    str,
-		conn:      conn,
-		buf:       make([]byte, 16),
-		datagrams: datagrams,
+		Stream:       str,
+		conn:         conn,
+		buf:          make([]byte, 16),
+		datagrams:    datagrams,
+		parseTrailer: parseTrailer,
 	}
 }
 
@@ -75,12 +79,21 @@ func (s *stream) Read(b []byte) (int, error) {
 				return 0, err
 			}
 			switch f := frame.(type) {
-			case *headersFrame:
-				// skip HEADERS frames
-				continue
 			case *dataFrame:
+				if s.parsedTrailer {
+					return 0, errors.New("DATA frame received after trailers")
+				}
 				s.bytesRemainingInFrame = f.Length
 				break parseLoop
+			case *headersFrame:
+				if s.conn.perspective == protocol.PerspectiveServer {
+					continue
+				}
+				if s.parsedTrailer {
+					return 0, errors.New("additional HEADERS frame received after trailers")
+				}
+				s.parsedTrailer = true
+				return 0, s.parseTrailer(s.Stream, f.Length)
 			default:
 				s.conn.CloseWithError(quic.ApplicationErrorCode(ErrCodeFrameUnexpected), "")
 				// parseNextFrame skips over unknown frame types
@@ -134,6 +147,7 @@ type requestStream struct {
 	maxHeaderBytes     uint64
 	reqDone            chan<- struct{}
 	disableCompression bool
+	response           *http.Response
 
 	sentRequest   bool
 	requestedGzip bool
@@ -149,6 +163,7 @@ func newRequestStream(
 	decoder *qpack.Decoder,
 	disableCompression bool,
 	maxHeaderBytes uint64,
+	rsp *http.Response,
 ) *requestStream {
 	return &requestStream{
 		stream:             str,
@@ -157,6 +172,7 @@ func newRequestStream(
 		decoder:            decoder,
 		disableCompression: disableCompression,
 		maxHeaderBytes:     maxHeaderBytes,
+		response:           rsp,
 	}
 }
 
@@ -213,9 +229,8 @@ func (s *requestStream) ReadResponse() (*http.Response, error) {
 		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 {
+	res := s.response
+	if err := updateResponseFromHeaders(res, hfs); err != nil {
 		s.Stream.CancelRead(quic.StreamErrorCode(ErrCodeMessageError))
 		s.Stream.CancelWrite(quic.StreamErrorCode(ErrCodeMessageError))
 		return nil, fmt.Errorf("http3: invalid response: %w", err)
diff --git a/http3/server.go b/http3/server.go
index 9e7cd644..d3e43673 100644
--- a/http3/server.go
+++ b/http3/server.go
@@ -571,7 +571,7 @@ func (s *Server) handleRequest(conn *connection, str quic.Stream, datagrams *dat
 	if _, ok := req.Header["Content-Length"]; ok && req.ContentLength >= 0 {
 		contentLength = req.ContentLength
 	}
-	hstr := newStream(str, conn, datagrams)
+	hstr := newStream(str, conn, datagrams, nil)
 	body := newRequestBody(hstr, contentLength, conn.Context(), conn.ReceivedSettings(), conn.Settings)
 	req.Body = body
 
diff --git a/internal/handshake/crypto_setup.go b/internal/handshake/crypto_setup.go
index 0fb75dc8..c8e6cb33 100644
--- a/internal/handshake/crypto_setup.go
+++ b/internal/handshake/crypto_setup.go
@@ -229,6 +229,9 @@ func (h *cryptoSetup) handleMessage(data []byte, encLevel protocol.EncryptionLev
 }
 
 func (h *cryptoSetup) handleEvent(ev tls.QUICEvent) (done bool, err error) {
+	//nolint:exhaustive
+	// Go 1.23 added new 0-RTT events, see https://github.com/quic-go/quic-go/issues/4272.
+	// We will start using these events when dropping support for Go 1.22.
 	switch ev.Kind {
 	case tls.QUICNoEvent:
 		return true, nil
diff --git a/interop/Dockerfile b/interop/Dockerfile
index 6c34fae6..070e597e 100644
--- a/interop/Dockerfile
+++ b/interop/Dockerfile
@@ -5,7 +5,7 @@ RUN echo "TARGETPLATFORM: ${TARGETPLATFORM}"
 
 RUN apt-get update && apt-get install -y wget tar git
 
-ENV GOVERSION=1.22.0
+ENV GOVERSION=1.23.0
 
 RUN platform=$(echo ${TARGETPLATFORM} | tr '/' '-') && \
   filename="go${GOVERSION}.${platform}.tar.gz" && \
diff --git a/oss-fuzz.sh b/oss-fuzz.sh
index 22a577fe..92a57a2c 100644
--- a/oss-fuzz.sh
+++ b/oss-fuzz.sh
@@ -3,12 +3,12 @@
 # Install Go manually, since oss-fuzz ships with an outdated Go version.
 # See https://github.com/google/oss-fuzz/pull/10643.
 export CXX="${CXX} -lresolv" # required by Go 1.20
-wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz \
+wget https://go.dev/dl/go1.23.0.linux-amd64.tar.gz \
   && mkdir temp-go \
   && rm -rf /root/.go/* \
-  && tar -C temp-go/ -xzf go1.22.0.linux-amd64.tar.gz \
+  && tar -C temp-go/ -xzf go1.23.0.linux-amd64.tar.gz \
   && mv temp-go/go/* /root/.go/ \
-  && rm -rf temp-go go1.22.0.linux-amd64.tar.gz
+  && rm -rf temp-go go1.23.0.linux-amd64.tar.gz
 
 (
 # fuzz qpack


Test patch:
diff --git a/http3/client_test.go b/http3/client_test.go
index 4e3b0f23..9773274f 100644
--- a/http3/client_test.go
+++ b/http3/client_test.go
@@ -27,7 +27,7 @@ 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), nil, false, nil)
+	rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return 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")
@@ -580,6 +580,139 @@ var _ = Describe("Client", func() {
 			Expect(rsp.Request).ToNot(BeNil())
 		})
 
+		It("returns a response with trailers", func() {
+			rspBuf := bytes.NewBuffer(encodeResponse(418))
+
+			trailerBuf := &bytes.Buffer{}
+			enc := qpack.NewEncoder(trailerBuf)
+			Expect(enc.WriteField(qpack.HeaderField{Name: "Grpc-Status", Value: "0"})).To(Succeed())
+			Expect(enc.Close()).To(Succeed())
+			b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
+			b = append(b, trailerBuf.Bytes()...)
+			rspBuf.Write(b)
+
+			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().Close()
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
+			rsp, err := cl.RoundTrip(req)
+			Expect(err).ToNot(HaveOccurred())
+			_, err = io.ReadAll(rsp.Body)
+			Expect(err).ToNot(HaveOccurred())
+			Expect(rsp.Trailer).To(Equal(http.Header{"Grpc-Status": []string{"0"}}))
+			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
+			Expect(rsp.ProtoMajor).To(Equal(3))
+			Expect(rsp.StatusCode).To(Equal(418))
+			Expect(rsp.Request).ToNot(BeNil())
+		})
+
+		It("errors on invalid HEADERS frames", func() {
+			rspBuf := bytes.NewBuffer(encodeResponse(418))
+
+			b := (&headersFrame{Length: 10}).Append(nil)
+			b = append(b, []byte("invalid headers frame")...)
+			rspBuf.Write(b)
+
+			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().Close()
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
+			rsp, err := cl.RoundTrip(req)
+			Expect(err).ToNot(HaveOccurred())
+			_, err = io.ReadAll(rsp.Body)
+			Expect(err).To(HaveOccurred())
+		})
+
+		It("returns an error if trailers are sent twice", func() {
+			rspBuf := bytes.NewBuffer(encodeResponse(418))
+
+			{
+				trailerBuf := &bytes.Buffer{}
+				enc := qpack.NewEncoder(trailerBuf)
+				Expect(enc.WriteField(qpack.HeaderField{Name: "Grpc-Status", Value: "0"})).To(Succeed())
+				Expect(enc.Close()).To(Succeed())
+				b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
+				b = append(b, trailerBuf.Bytes()...)
+				rspBuf.Write(b)
+			}
+
+			{
+				trailerBuf := &bytes.Buffer{}
+				enc := qpack.NewEncoder(trailerBuf)
+				Expect(enc.WriteField(qpack.HeaderField{Name: "Grpc-Status", Value: "1"})).To(Succeed())
+				Expect(enc.Close()).To(Succeed())
+				b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
+				b = append(b, trailerBuf.Bytes()...)
+				rspBuf.Write(b)
+			}
+
+			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().Close()
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
+			rsp, err := cl.RoundTrip(req)
+			Expect(err).ToNot(HaveOccurred())
+			_, err = io.ReadAll(rsp.Body)
+			Expect(err).To(MatchError(errors.New("additional HEADERS frame received after trailers")))
+			Expect(rsp.Trailer).To(Equal(http.Header{"Grpc-Status": []string{"0"}}))
+			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
+			Expect(rsp.ProtoMajor).To(Equal(3))
+			Expect(rsp.StatusCode).To(Equal(418))
+			Expect(rsp.Request).ToNot(BeNil())
+		})
+
+		It("returns an error if body frame is received after trailers", func() {
+			rspBuf := bytes.NewBuffer(encodeResponse(418))
+
+			{
+				trailerBuf := &bytes.Buffer{}
+				enc := qpack.NewEncoder(trailerBuf)
+				Expect(enc.WriteField(qpack.HeaderField{Name: "Grpc-Status", Value: "0"})).To(Succeed())
+				Expect(enc.Close()).To(Succeed())
+				b := (&headersFrame{Length: uint64(trailerBuf.Len())}).Append(nil)
+				b = append(b, trailerBuf.Bytes()...)
+				rspBuf.Write(b)
+			}
+
+			{
+				dataBuf := &bytes.Buffer{}
+				dataBuf.Write([]byte("test body please ignore"))
+				b := (&dataFrame{Length: uint64(dataBuf.Len())}).Append(nil)
+				b = append(b, dataBuf.Bytes()...)
+				rspBuf.Write(b)
+			}
+
+			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().Close()
+			str.EXPECT().Read(gomock.Any()).DoAndReturn(rspBuf.Read).AnyTimes()
+			rsp, err := cl.RoundTrip(req)
+			Expect(err).ToNot(HaveOccurred())
+			_, err = io.ReadAll(rsp.Body)
+			Expect(err).To(MatchError(errors.New("DATA frame received after trailers")))
+			Expect(rsp.Trailer).To(Equal(http.Header{"Grpc-Status": []string{"0"}}))
+			Expect(rsp.Proto).To(Equal("HTTP/3.0"))
+			Expect(rsp.ProtoMajor).To(Equal(3))
+			Expect(rsp.StatusCode).To(Equal(418))
+			Expect(rsp.Request).ToNot(BeNil())
+		})
+
 		Context("requests containing a Body", func() {
 			var strBuf *bytes.Buffer
 
@@ -842,7 +975,7 @@ var _ = Describe("Client", func() {
 				rstr := mockquic.NewMockStream(mockCtrl)
 				rstr.EXPECT().StreamID().AnyTimes()
 				rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-				rw := newResponseWriter(newStream(rstr, nil, nil), nil, false, nil)
+				rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
 				rw.Header().Set("Content-Encoding", "gzip")
 				gz := gzip.NewWriter(rw)
 				gz.Write([]byte("gzipped response"))
@@ -869,7 +1002,7 @@ var _ = Describe("Client", func() {
 				rstr := mockquic.NewMockStream(mockCtrl)
 				rstr.EXPECT().StreamID().AnyTimes()
 				rstr.EXPECT().Write(gomock.Any()).Do(buf.Write).AnyTimes()
-				rw := newResponseWriter(newStream(rstr, nil, nil), nil, false, nil)
+				rw := newResponseWriter(newStream(rstr, nil, nil, func(r io.Reader, u uint64) error { return 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 })
diff --git a/http3/headers_test.go b/http3/headers_test.go
index 89c76e5b..b1b52f5a 100644
--- a/http3/headers_test.go
+++ b/http3/headers_test.go
@@ -309,7 +309,8 @@ var _ = Describe("Response", func() {
 			{Name: ":status", Value: "200"},
 			{Name: "content-length", Value: "42"},
 		}
-		rsp, err := responseFromHeaders(headers)
+		rsp := &http.Response{}
+		err := updateResponseFromHeaders(rsp, headers)
 		Expect(err).NotTo(HaveOccurred())
 		Expect(rsp.Proto).To(Equal("HTTP/3.0"))
 		Expect(rsp.ProtoMajor).To(Equal(3))
@@ -327,7 +328,7 @@ var _ = Describe("Response", func() {
 			{Name: "content-length", Value: "42"},
 			{Name: ":status", Value: "200"},
 		}
-		_, err := responseFromHeaders(headers)
+		err := updateResponseFromHeaders(&http.Response{}, headers)
 		Expect(err).To(MatchError("received pseudo header :status after a regular header field"))
 	})
 
@@ -335,7 +336,7 @@ var _ = Describe("Response", func() {
 		headers := []qpack.HeaderField{
 			{Name: "content-length", Value: "42"},
 		}
-		_, err := responseFromHeaders(headers)
+		err := updateResponseFromHeaders(&http.Response{}, headers)
 		Expect(err).To(MatchError("missing status field"))
 	})
 
@@ -344,7 +345,7 @@ var _ = Describe("Response", func() {
 			{Name: ":status", Value: "foobar"},
 			{Name: "content-length", Value: "42"},
 		}
-		_, err := responseFromHeaders(headers)
+		err := updateResponseFromHeaders(&http.Response{}, headers)
 		Expect(err).To(HaveOccurred())
 		Expect(err.Error()).To(ContainSubstring("invalid status code"))
 	})
@@ -354,7 +355,7 @@ var _ = Describe("Response", func() {
 			{Name: ":status", Value: "404"},
 			{Name: ":method", Value: "GET"},
 		}
-		_, err := responseFromHeaders(headers)
+		err := updateResponseFromHeaders(&http.Response{}, headers)
 		Expect(err).To(MatchError("invalid response pseudo header: :method"))
 	})
 })
diff --git a/http3/http_stream_test.go b/http3/http_stream_test.go
index 59f68279..84d50cd0 100644
--- a/http3/http_stream_test.go
+++ b/http3/http_stream_test.go
@@ -26,7 +26,7 @@ func getDataFrame(data []byte) []byte {
 var _ = Describe("Stream", func() {
 	Context("reading", func() {
 		var (
-			str           Stream
+			str           *stream
 			qstr          *mockquic.MockStream
 			buf           *bytes.Buffer
 			errorCbCalled bool
@@ -43,7 +43,7 @@ var _ = Describe("Stream", func() {
 				errorCbCalled = true
 				return nil
 			}).AnyTimes()
-			str = newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil, 0), nil)
+			str = newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil, 0), nil, func(r io.Reader, u uint64) error { return nil })
 		})
 
 		It("reads DATA frames in a single run", func() {
@@ -104,19 +104,6 @@ var _ = Describe("Stream", func() {
 			Expect(b[:n]).To(Equal([]byte("bar")))
 		})
 
-		It("skips HEADERS frames", func() {
-			b := getDataFrame([]byte("foo"))
-			b = (&headersFrame{Length: 10}).Append(b)
-			b = append(b, make([]byte, 10)...)
-			b = append(b, getDataFrame([]byte("bar"))...)
-			buf.Write(b)
-			r := make([]byte, 6)
-			n, err := io.ReadFull(str, r)
-			Expect(err).ToNot(HaveOccurred())
-			Expect(n).To(Equal(6))
-			Expect(r).To(Equal([]byte("foobar")))
-		})
-
 		It("errors when it can't parse the frame", func() {
 			buf.Write([]byte("invalid"))
 			_, err := str.Read([]byte{0})
@@ -137,7 +124,7 @@ var _ = Describe("Stream", func() {
 			buf := &bytes.Buffer{}
 			qstr := mockquic.NewMockStream(mockCtrl)
 			qstr.EXPECT().Write(gomock.Any()).DoAndReturn(buf.Write).AnyTimes()
-			str := newStream(qstr, nil, nil)
+			str := newStream(qstr, nil, nil, func(r io.Reader, u uint64) error { return nil })
 			str.Write([]byte("foo"))
 			str.Write([]byte("foobar"))
 
@@ -171,12 +158,13 @@ var _ = Describe("Request Stream", func() {
 		requestWriter := newRequestWriter()
 		conn := mockquic.NewMockEarlyConnection(mockCtrl)
 		str = newRequestStream(
-			newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil, 0), nil),
+			newStream(qstr, newConnection(context.Background(), conn, false, protocol.PerspectiveClient, nil, 0), nil, func(r io.Reader, u uint64) error { return nil }),
 			requestWriter,
 			make(chan struct{}),
 			qpack.NewDecoder(func(qpack.HeaderField) {}),
 			true,
 			math.MaxUint64,
+			&http.Response{},
 		)
 	})
 
diff --git a/http3/response_writer_test.go b/http3/response_writer_test.go
index dbac1761..3394043a 100644
--- a/http3/response_writer_test.go
+++ b/http3/response_writer_test.go
@@ -26,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(newStream(str, nil, nil), nil, false, nil)
+		rw = newResponseWriter(newStream(str, nil, nil, func(r io.Reader, u uint64) error { return nil }), nil, false, nil)
 	})
 
 	decodeHeader := func(str io.Reader) map[string][]string {
diff --git a/integrationtests/gomodvendor/go.mod b/integrationtests/gomodvendor/go.mod
index 2594be4e..7100b58e 100644
--- a/integrationtests/gomodvendor/go.mod
+++ b/integrationtests/gomodvendor/go.mod
@@ -1,6 +1,8 @@
 module test
 
-go 1.21
+go 1.22
+
+toolchain go1.22.4
 
 // The version doesn't matter here, as we're replacing it with the currently checked out code anyway.
 require github.com/quic-go/quic-go v0.21.0