Skip to content

Commit d311ade

Browse files
quaesitor-scientiamRichard Wheelerclaude
authored
net.http: negotiate HTTP/2 in fetch via ALPN (opt-in) (#27362)
* net.http: negotiate HTTP/2 in fetch via ALPN (opt-in) Wire the HTTP/2 client connection into the existing fetch path so a single http.fetch call transparently speaks HTTP/2 when the server supports it. - New opt-in field `enable_http2` on Request and FetchConfig (default false, so existing callers are unaffected and nothing changes on the wire). - In net_ssl_do, when enabled, advertise ALPN `h2, http/1.1`. After the handshake, if the server selected `h2`, run the request over H2Conn; otherwise fall back to the existing HTTP/1.1 path unchanged. - Conversion glue (h2_client.v, pure/backend-agnostic): build an H2 request from a net.http Request — lowercasing header names, dropping hop-by-hop headers, mapping Host to the :authority pseudo-header, and collapsing cookies — and convert the H2 response back to a net.http Response (decoding Content-Encoding the same way the HTTP/1.1 path does, and reporting version 2.0). Usage: resp := http.fetch(url: 'https://example.com/', enable_http2: true)! assert resp.version() == .v2_0 // when the server offers h2 Tests: pure unit tests for the request/response conversions, a full glue round-trip (Request -> H2Conn over an in-memory transport -> Response), and an opt-in end-to-end test against a real h2 server (run with `-d network`). Verified manually against https://www.google.com/ (HTTP/2.0, 200), with the default path still selecting HTTP/1.1. Passes under -W -cstrict -cc clang. Depends on the H2Conn client (#27361). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * net.http: honor explicit Host and keep streaming on HTTP/1.1 for h2 fetch Address two send-path correctness gaps in the HTTP/2 fetch shim: - Derive the :authority pseudo-header from an explicit Host header when the caller set one, matching the HTTP/1.1 path. Previously virtual-host / host-override requests sent the URL host as :authority over HTTP/2 even though they worked over HTTP/1.1. - Do not negotiate HTTP/2 for requests that use streaming response callbacks (on_progress / on_progress_body) or stop limits (stop_copying_limit / stop_receiving_limit). The HTTP/2 path buffers the full response, so those would be silently ignored. The ALPN offer is now gated on this before negotiation — a server cannot select h2 for such requests — so they keep the existing HTTP/1.1 streaming behavior. (Streaming over HTTP/2 is a planned follow-up.) The enable_http2 docs note this. Adds tests for Host-derived authority and for uses_response_streaming(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Richard Wheeler <quaesitor.scientiam@gmail.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 03c6723 commit d311ade

6 files changed

Lines changed: 285 additions & 0 deletions

File tree

‎vlib/net/http/backend.c.v‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,22 @@ fn net_ssl_do(req &Request, port int, method Method, host_name string, path stri
2121
eprint(req_headers)
2222
eprintln('')
2323
}
24+
// Advertise ALPN `h2` (with an `http/1.1` fallback) only when HTTP/2 is
25+
// requested, so existing callers see no change on the wire. Requests that
26+
// rely on streaming response callbacks or stop limits stay on HTTP/1.1,
27+
// since the HTTP/2 path buffers the full response; the ALPN offer is gated
28+
// here (before negotiation) because once a server selects `h2` we cannot
29+
// fall back to HTTP/1.1 framing on the same connection.
30+
use_h2 := req.enable_http2 && !req.uses_response_streaming()
31+
alpn := if use_h2 { ['h2', 'http/1.1'] } else { []string{} }
2432
for {
2533
mut ssl_conn := ssl.new_ssl_conn(
2634
verify: req.verify
2735
cert: req.cert
2836
cert_key: req.cert_key
2937
validate: req.validate
3038
in_memory_verification: req.in_memory_verification
39+
alpn_protocols: alpn
3140
)!
3241
ssl_conn.dial(host_name, port) or {
3342
retries++
@@ -42,11 +51,32 @@ fn net_ssl_do(req &Request, port int, method Method, host_name string, path stri
4251
if req.read_timeout > 0 {
4352
ssl_conn.set_read_timeout(req.read_timeout)
4453
}
54+
// If the server negotiated HTTP/2 via ALPN, speak it; otherwise fall
55+
// back to the existing HTTP/1.1 path unchanged.
56+
if use_h2 && ssl_conn.negotiated_alpn() == 'h2' {
57+
return req.h2_do(mut ssl_conn, method, host_name, port, path, data, header)!
58+
}
4559
return req.do_request(req_headers, mut ssl_conn)!
4660
}
4761
return error('http.net_ssl_do: exhausted retries')
4862
}
4963

64+
// h2_do runs a single request over an HTTP/2 connection on an already-dialled,
65+
// ALPN-negotiated `h2` TLS socket, and returns the response as a net.http
66+
// Response.
67+
fn (req &Request) h2_do(mut ssl_conn ssl.SSLConn, method Method, host_name string, port int, path string, data string, header Header) !Response {
68+
defer {
69+
ssl_conn.shutdown() or {}
70+
}
71+
h2req := req.to_h2_request(method, h2_authority(host_name, port), path, data, header)
72+
mut conn := new_h2_conn(ssl_conn)
73+
h2resp := conn.do(h2req)!
74+
if req.on_finish != unsafe { nil } {
75+
req.on_finish(req, u64(h2resp.body.len))!
76+
}
77+
return h2_response_to_http(h2resp)
78+
}
79+
5080
fn read_from_ssl_connection_cb(con voidptr, buf &u8, bufsize int) !int {
5181
mut ssl_conn := unsafe { &ssl.SSLConn(con) }
5282
return ssl_conn.socket_read_into_ptr(buf, bufsize)

‎vlib/net/http/h2_client.v‎

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Copyright (c) 2019-2024 Alexander Medvednikov. All rights reserved.
2+
// Use of this source code is governed by an MIT license
3+
// that can be found in the LICENSE file.
4+
module http
5+
6+
// This file converts between net.http's Request/Response and the HTTP/2
7+
// client types in h2_conn.v. The actual transport wiring (ALPN negotiation on
8+
// the TLS socket) lives in backend.c.v; these helpers are pure and backend
9+
// agnostic, so they can be tested without a socket.
10+
11+
// h2_hop_by_hop are header names that must not be forwarded on HTTP/2
12+
// (RFC 7540 Section 8.1.2.2), plus `host` (replaced by the :authority
13+
// pseudo-header) and `cookie` (handled specially below).
14+
const h2_hop_by_hop = ['connection', 'keep-alive', 'proxy-connection', 'transfer-encoding', 'upgrade',
15+
'host', 'cookie']
16+
17+
// h2_authority returns the :authority value for a host and port, omitting the
18+
// port for the default HTTPS port.
19+
fn h2_authority(host string, port int) string {
20+
if port == 443 || port == 0 {
21+
return host
22+
}
23+
return '${host}:${port}'
24+
}
25+
26+
// to_h2_request builds an HTTP/2 request from this request. Header names are
27+
// lowercased, hop-by-hop headers are dropped, the Host header becomes the
28+
// :authority pseudo-header, and cookies are collapsed into a single field.
29+
fn (req &Request) to_h2_request(method Method, authority string, path string, data string, header Header) H2ClientRequest {
30+
// An explicit Host header overrides the URL host, matching the HTTP/1.1
31+
// path (used for virtual-host / host-override requests).
32+
mut auth := authority
33+
if host := header.get(.host) {
34+
if host != '' {
35+
auth = host
36+
}
37+
}
38+
mut extra := []H2HeaderField{}
39+
if !header.contains(.user_agent) && req.user_agent != '' {
40+
extra << H2HeaderField{'user-agent', req.user_agent}
41+
}
42+
if data.len > 0 && !header.contains(.content_length) {
43+
extra << H2HeaderField{'content-length', data.len.str()}
44+
}
45+
for key in header.keys() {
46+
lkey := key.to_lower()
47+
if lkey in h2_hop_by_hop {
48+
continue
49+
}
50+
for val in header.custom_values(key) {
51+
extra << H2HeaderField{lkey, val}
52+
}
53+
}
54+
// Cookies: the request's own cookie map plus any Cookie header values,
55+
// joined into one field (RFC 7540 Section 8.1.2.5 also allows splitting).
56+
mut cookie_parts := []string{}
57+
for k, v in req.cookies {
58+
cookie_parts << '${k}=${v}'
59+
}
60+
for cv in header.values(.cookie) {
61+
cookie_parts << cv
62+
}
63+
if cookie_parts.len > 0 {
64+
extra << H2HeaderField{'cookie', cookie_parts.join('; ')}
65+
}
66+
return H2ClientRequest{
67+
method: method.str()
68+
scheme: 'https'
69+
authority: auth
70+
path: path
71+
headers: extra
72+
body: data.bytes()
73+
}
74+
}
75+
76+
// uses_response_streaming reports whether the request relies on streaming
77+
// response callbacks or stop limits. The HTTP/2 path buffers the full response,
78+
// so such requests must not negotiate HTTP/2 and instead use the HTTP/1.1 path,
79+
// which honors these. (Streaming over HTTP/2 is a planned follow-up.)
80+
fn (req &Request) uses_response_streaming() bool {
81+
return req.on_progress != unsafe { nil } || req.on_progress_body != unsafe { nil }
82+
|| req.stop_copying_limit >= 0 || req.stop_receiving_limit >= 0
83+
}
84+
85+
// h2_response_to_http converts an HTTP/2 response into a net.http Response,
86+
// decoding any Content-Encoding the same way the HTTP/1.1 path does.
87+
fn h2_response_to_http(h2resp H2ClientResponse) Response {
88+
mut h := new_header()
89+
for f in h2resp.headers {
90+
h.add_custom(f.name, f.value) or {}
91+
}
92+
body := decode_response_body(h2resp.body.bytestr(), h.get(.content_encoding) or { '' })
93+
status := status_from_int(h2resp.status)
94+
return Response{
95+
http_version: '2.0'
96+
status_code: h2resp.status
97+
status_msg: status.str()
98+
header: h
99+
body: body
100+
}
101+
}

‎vlib/net/http/h2_client_test.v‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
module http
2+
3+
// Tests for the HTTP/2 <-> net.http conversion glue (h2_client.v). The
4+
// request/response conversions are pure and need no socket; the end-to-end
5+
// fetch test is opt-in and network-dependent.
6+
7+
fn test_to_h2_request_pseudo_headers_and_body() {
8+
req := Request{
9+
user_agent: 'v.http'
10+
}
11+
h2req := req.to_h2_request(.post, 'example.com', '/p?q=1', 'hello', new_header())
12+
assert h2req.method == 'POST'
13+
assert h2req.scheme == 'https'
14+
assert h2req.authority == 'example.com'
15+
assert h2req.path == '/p?q=1'
16+
assert h2req.body.bytestr() == 'hello'
17+
// user-agent (from the request) and a synthesized content-length.
18+
assert h2req.headers.any(it.name == 'user-agent' && it.value == 'v.http')
19+
assert h2req.headers.any(it.name == 'content-length' && it.value == '5')
20+
}
21+
22+
fn test_to_h2_request_lowercases_and_keeps_custom_headers() {
23+
mut h := new_header()
24+
h.add_custom('Accept', 'application/json') or {}
25+
h.add(.content_type, 'text/plain')
26+
req := Request{}
27+
h2req := req.to_h2_request(.get, 'h.example', '/', '', h)
28+
assert h2req.headers.any(it.name == 'accept' && it.value == 'application/json')
29+
assert h2req.headers.any(it.name == 'content-type' && it.value == 'text/plain')
30+
}
31+
32+
fn test_to_h2_request_strips_hop_by_hop_and_host() {
33+
mut h := new_header()
34+
h.add(.connection, 'keep-alive')
35+
h.add(.host, 'example.com')
36+
h.add_custom('Transfer-Encoding', 'chunked') or {}
37+
req := Request{}
38+
h2req := req.to_h2_request(.get, 'example.com', '/', '', h)
39+
assert !h2req.headers.any(it.name == 'connection')
40+
assert !h2req.headers.any(it.name == 'host')
41+
assert !h2req.headers.any(it.name == 'transfer-encoding')
42+
}
43+
44+
fn test_to_h2_request_collapses_cookies() {
45+
mut h := new_header()
46+
h.add(.cookie, 'a=1')
47+
req := Request{
48+
cookies: {
49+
'sid': 'abc'
50+
}
51+
}
52+
h2req := req.to_h2_request(.get, 'h.example', '/', '', h)
53+
cookie := h2req.headers.filter(it.name == 'cookie')
54+
assert cookie.len == 1
55+
// Both the request cookie map and the Cookie header value are present.
56+
assert cookie[0].value.contains('sid=abc')
57+
assert cookie[0].value.contains('a=1')
58+
}
59+
60+
fn test_h2_response_to_http() {
61+
h2resp := H2ClientResponse{
62+
status: 200
63+
headers: [H2HeaderField{'content-type', 'text/plain'},
64+
H2HeaderField{'x-foo', 'bar'}]
65+
body: 'hi'.bytes()
66+
}
67+
resp := h2_response_to_http(h2resp)
68+
assert resp.status_code == 200
69+
assert resp.http_version == '2.0'
70+
assert resp.version() == .v2_0
71+
assert resp.body == 'hi'
72+
assert (resp.header.get_custom('content-type') or { '' }) == 'text/plain'
73+
assert (resp.header.get_custom('x-foo') or { '' }) == 'bar'
74+
}
75+
76+
fn test_h2_authority_omits_default_port() {
77+
assert h2_authority('example.com', 443) == 'example.com'
78+
assert h2_authority('example.com', 0) == 'example.com'
79+
assert h2_authority('example.com', 8443) == 'example.com:8443'
80+
}
81+
82+
// Opt-in end-to-end test against a real HTTP/2 server. Run with `-d network`,
83+
// e.g. `v -d network test vlib/net/http/h2_client_test.v`.
84+
fn test_http2_fetch_real_server() {
85+
$if !network ? {
86+
return
87+
}
88+
resp := fetch(url: 'https://www.google.com/', enable_http2: true)!
89+
assert resp.version() == .v2_0
90+
assert resp.status_code == 200
91+
assert resp.body.len > 0
92+
// Without opting in, the same server is reached over HTTP/1.1.
93+
plain := get('https://www.google.com/')!
94+
assert plain.version() == .v1_1
95+
}
96+
97+
fn test_to_h2_request_authority_from_host_header() {
98+
mut h := new_header()
99+
h.add(.host, 'override.example:8443')
100+
req := Request{}
101+
// The URL host is origin.example, but an explicit Host header must win.
102+
h2req := req.to_h2_request(.get, 'origin.example', '/', '', h)
103+
assert h2req.authority == 'override.example:8443'
104+
assert !h2req.headers.any(it.name == 'host')
105+
}
106+
107+
fn test_uses_response_streaming() {
108+
assert !(Request{}).uses_response_streaming()
109+
assert (Request{
110+
stop_copying_limit: 0
111+
}).uses_response_streaming()
112+
assert (Request{
113+
stop_receiving_limit: 100
114+
}).uses_response_streaming()
115+
progress := fn (request &Request, chunk []u8, read_so_far u64) ! {}
116+
assert (Request{
117+
on_progress: progress
118+
}).uses_response_streaming()
119+
body_progress := fn (request &Request, chunk []u8, body_read_so_far u64, body_expected_size u64, status_code int) ! {}
120+
assert (Request{
121+
on_progress_body: body_progress
122+
}).uses_response_streaming()
123+
// A non-streaming callback (on_finish) does not force HTTP/1.1.
124+
finish := fn (request &Request, final_size u64) ! {}
125+
assert !(Request{
126+
on_finish: finish
127+
}).uses_response_streaming()
128+
}

‎vlib/net/http/h2_conn_test.v‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,26 @@ fn test_h2_conn_large_body_flow_control() {
377377
assert sent == body.len
378378
assert ended
379379
}
380+
381+
fn test_h2_fetch_glue_roundtrip() {
382+
// Drive the full conversion glue (Request -> H2 -> Response) over the mock
383+
// transport, without a socket.
384+
inbound := build_server_stream([
385+
H2HeaderField{':status', '200'},
386+
H2HeaderField{'content-type', 'text/plain'},
387+
], [' world'.bytes()])
388+
mut t := &MockTransport{
389+
inbound: inbound
390+
}
391+
req := Request{
392+
user_agent: 'v.http'
393+
}
394+
h2req := req.to_h2_request(.get, 'example.com', '/', '', new_header())
395+
mut c := new_h2_conn(t)
396+
h2resp := c.do(h2req)!
397+
resp := h2_response_to_http(h2resp)
398+
assert resp.status_code == 200
399+
assert resp.version() == .v2_0
400+
assert resp.body == ' world'
401+
assert (resp.header.get_custom('content-type') or { '' }) == 'text/plain'
402+
}

‎vlib/net/http/http.v‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub mut:
3636
in_memory_verification bool // if true, verify, cert, and cert_key are read from memory, not from a file
3737
allow_redirect bool = true // whether to allow redirect
3838
max_retries int = 5 // maximum number of retries required when an underlying socket error occurs
39+
enable_http2 bool // opt in to HTTP/2 over TLS: advertise ALPN `h2` and, if the server selects it, speak HTTP/2 instead of HTTP/1.1. Requests using streaming response callbacks (on_progress/on_progress_body) or stop limits stay on HTTP/1.1, since the HTTP/2 path buffers the full response.
3940
// callbacks to allow custom reporting code to run, while the request is running, and to implement streaming
4041
on_redirect RequestRedirectFn = unsafe { nil }
4142
on_progress RequestProgressFn = unsafe { nil }
@@ -188,6 +189,7 @@ pub fn prepare(config FetchConfig) !Request {
188189
in_memory_verification: config.in_memory_verification
189190
allow_redirect: config.allow_redirect
190191
max_retries: config.max_retries
192+
enable_http2: config.enable_http2
191193
on_progress: config.on_progress
192194
on_progress_body: config.on_progress_body
193195
on_redirect: config.on_redirect

‎vlib/net/http/request.v‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ pub mut:
4646
in_memory_verification bool // if true, verify, cert, and cert_key are read from memory, not from a file
4747
allow_redirect bool = true // whether to allow redirect
4848
max_retries int = 5 // maximum number of retries required when an underlying socket error occurs
49+
enable_http2 bool // opt in to HTTP/2 over TLS: advertise ALPN `h2` and, if the server selects it, speak HTTP/2 instead of HTTP/1.1. Requests using streaming response callbacks (on_progress/on_progress_body) or stop limits stay on HTTP/1.1, since the HTTP/2 path buffers the full response.
4950
// callbacks to allow custom reporting code to run, while the request is running, and to implement streaming
5051
on_redirect RequestRedirectFn = unsafe { nil }
5152
on_progress RequestProgressFn = unsafe { nil }

0 commit comments

Comments
 (0)