Skip to content

Commit 145b2d7

Browse files
neildgopherbot
authored andcommitted
internal/http3: add RoundTrip
Send request headers, receive response headers. For golang/go#70914 Change-Id: I78d4dcc69c253ed7ad1543dfc3c5d8f1c321ced9 Reviewed-on: https://go-review.googlesource.com/c/net/+/644118 LUCI-TryBot-Result: Go LUCI <[email protected]> Reviewed-by: Jonathan Amsterdam <[email protected]> Auto-Submit: Damien Neil <[email protected]>
1 parent 5bda71a commit 145b2d7

File tree

4 files changed

+621
-15
lines changed

4 files changed

+621
-15
lines changed

internal/http3/roundtrip.go

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// Copyright 2025 The Go Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
5+
//go:build go1.24
6+
7+
package http3
8+
9+
import (
10+
"io"
11+
"net/http"
12+
"strconv"
13+
14+
"golang.org/x/net/internal/httpcommon"
15+
)
16+
17+
// RoundTrip sends a request on the connection.
18+
func (cc *ClientConn) RoundTrip(req *http.Request) (_ *http.Response, err error) {
19+
// Each request gets its own QUIC stream.
20+
st, err := newConnStream(req.Context(), cc.qconn, streamTypeRequest)
21+
if err != nil {
22+
return nil, err
23+
}
24+
defer func() {
25+
switch e := err.(type) {
26+
case nil:
27+
case *connectionError:
28+
cc.abort(e)
29+
case *streamError:
30+
st.stream.CloseRead()
31+
st.stream.Reset(uint64(e.code))
32+
default:
33+
st.stream.CloseRead()
34+
st.stream.Reset(uint64(errH3NoError))
35+
}
36+
}()
37+
38+
// Cancel reads/writes on the stream when the request expires.
39+
st.stream.SetReadContext(req.Context())
40+
st.stream.SetWriteContext(req.Context())
41+
42+
var encr httpcommon.EncodeHeadersResult
43+
headers := cc.enc.encode(func(yield func(itype indexType, name, value string)) {
44+
encr, err = httpcommon.EncodeHeaders(httpcommon.EncodeHeadersParam{
45+
Request: req,
46+
AddGzipHeader: false, // TODO: add when appropriate
47+
PeerMaxHeaderListSize: 0,
48+
DefaultUserAgent: "Go-http-client/3",
49+
}, func(name, value string) {
50+
// Issue #71374: Consider supporting never-indexed fields.
51+
yield(mayIndex, name, value)
52+
})
53+
})
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
// Write the HEADERS frame.
59+
st.writeVarint(int64(frameTypeHeaders))
60+
st.writeVarint(int64(len(headers)))
61+
st.Write(headers)
62+
if err := st.Flush(); err != nil {
63+
return nil, err
64+
}
65+
66+
if encr.HasBody {
67+
// TODO: Send the request body.
68+
}
69+
70+
// Read the response headers.
71+
for {
72+
ftype, err := st.readFrameHeader()
73+
if err != nil {
74+
return nil, err
75+
}
76+
switch ftype {
77+
case frameTypeHeaders:
78+
statusCode, h, err := cc.handleHeaders(st)
79+
if err != nil {
80+
return nil, err
81+
}
82+
83+
if statusCode >= 100 && statusCode < 199 {
84+
// TODO: Handle 1xx responses.
85+
continue
86+
}
87+
88+
// We have the response headers.
89+
// Set up the response and return it to the caller.
90+
contentLength, err := parseResponseContentLength(req.Method, statusCode, h)
91+
if err != nil {
92+
return nil, err
93+
}
94+
resp := &http.Response{
95+
Proto: "HTTP/3.0",
96+
ProtoMajor: 3,
97+
Header: h,
98+
StatusCode: statusCode,
99+
Status: strconv.Itoa(statusCode) + " " + http.StatusText(statusCode),
100+
ContentLength: contentLength,
101+
Body: io.NopCloser(nil), // TODO: read the response body
102+
}
103+
// TODO: Automatic Content-Type: gzip decoding.
104+
return resp, nil
105+
case frameTypePushPromise:
106+
if err := cc.handlePushPromise(st); err != nil {
107+
return nil, err
108+
}
109+
default:
110+
if err := st.discardUnknownFrame(ftype); err != nil {
111+
return nil, err
112+
}
113+
}
114+
}
115+
}
116+
117+
func parseResponseContentLength(method string, statusCode int, h http.Header) (int64, error) {
118+
clens := h["Content-Length"]
119+
if len(clens) == 0 {
120+
return -1, nil
121+
}
122+
123+
// We allow duplicate Content-Length headers,
124+
// but only if they all have the same value.
125+
for _, v := range clens[1:] {
126+
if clens[0] != v {
127+
return -1, &streamError{errH3MessageError, "mismatching Content-Length headers"}
128+
}
129+
}
130+
131+
// "A server MUST NOT send a Content-Length header field in any response
132+
// with a status code of 1xx (Informational) or 204 (No Content).
133+
// A server MUST NOT send a Content-Length header field in any 2xx (Successful)
134+
// response to a CONNECT request [...]"
135+
// https://www.rfc-editor.org/rfc/rfc9110#section-8.6-8
136+
if (statusCode >= 100 && statusCode < 200) ||
137+
statusCode == 204 ||
138+
(method == "CONNECT" && statusCode >= 200 && statusCode < 300) {
139+
// This is a protocol violation, but a fairly harmless one.
140+
// Just ignore the header.
141+
return -1, nil
142+
}
143+
144+
contentLen, err := strconv.ParseUint(clens[0], 10, 63)
145+
if err != nil {
146+
return -1, &streamError{errH3MessageError, "invalid Content-Length header"}
147+
}
148+
return int64(contentLen), nil
149+
}
150+
151+
func (cc *ClientConn) handleHeaders(st *stream) (statusCode int, h http.Header, err error) {
152+
haveStatus := false
153+
cookie := ""
154+
// Issue #71374: Consider tracking the never-indexed status of headers
155+
// with the N bit set in their QPACK encoding.
156+
err = cc.dec.decode(st, func(_ indexType, name, value string) error {
157+
switch {
158+
case name == ":status":
159+
if haveStatus {
160+
return &streamError{errH3MessageError, "duplicate :status"}
161+
}
162+
haveStatus = true
163+
statusCode, err = strconv.Atoi(value)
164+
if err != nil {
165+
return &streamError{errH3MessageError, "invalid :status"}
166+
}
167+
case name[0] == ':':
168+
// "Endpoints MUST treat a request or response
169+
// that contains undefined or invalid
170+
// pseudo-header fields as malformed."
171+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-4.3-3
172+
return &streamError{errH3MessageError, "undefined pseudo-header"}
173+
case name == "cookie":
174+
// "If a decompressed field section contains multiple cookie field lines,
175+
// these MUST be concatenated into a single byte string [...]"
176+
// using the two-byte delimiter of "; "''
177+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-4.2.1-2
178+
if cookie == "" {
179+
cookie = value
180+
} else {
181+
cookie += "; " + value
182+
}
183+
default:
184+
if h == nil {
185+
h = make(http.Header)
186+
}
187+
// TODO: Use a per-connection canonicalization cache as we do in HTTP/2.
188+
// Maybe we could put this in the QPACK decoder and have it deliver
189+
// pre-canonicalized headers to us here?
190+
cname := httpcommon.CanonicalHeader(name)
191+
// TODO: Consider using a single []string slice for all headers,
192+
// as we do in the HTTP/1 and HTTP/2 cases.
193+
// This is a bit tricky, since we don't know the number of headers
194+
// at the start of decoding. Perhaps it's worth doing a two-pass decode,
195+
// or perhaps we should just allocate header value slices in
196+
// reasonably-sized chunks.
197+
h[cname] = append(h[cname], value)
198+
}
199+
return nil
200+
})
201+
if !haveStatus {
202+
// "[The :status] pseudo-header field MUST be included in all responses [...]"
203+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-4.3.2-1
204+
err = errH3MessageError
205+
}
206+
if cookie != "" {
207+
if h == nil {
208+
h = make(http.Header)
209+
}
210+
h["Cookie"] = []string{cookie}
211+
}
212+
if err := st.endFrame(); err != nil {
213+
return 0, nil, err
214+
}
215+
return statusCode, h, err
216+
}
217+
218+
func (cc *ClientConn) handlePushPromise(st *stream) error {
219+
// "A client MUST treat receipt of a PUSH_PROMISE frame that contains a
220+
// larger push ID than the client has advertised as a connection error of H3_ID_ERROR."
221+
// https://www.rfc-editor.org/rfc/rfc9114.html#section-7.2.5-5
222+
return &connectionError{
223+
code: errH3IDError,
224+
message: "PUSH_PROMISE received when no MAX_PUSH_ID has been sent",
225+
}
226+
}

0 commit comments

Comments
 (0)