Skip to content

net.http: negotiate HTTP/2 in fetch via ALPN (opt-in)#27362

Merged
JalonSolov merged 2 commits into
vlang:masterfrom
quaesitor-scientiam:net-http2-fetch-shim
Jun 6, 2026
Merged

net.http: negotiate HTTP/2 in fetch via ALPN (opt-in)#27362
JalonSolov merged 2 commits into
vlang:masterfrom
quaesitor-scientiam:net-http2-fetch-shim

Conversation

@quaesitor-scientiam

@quaesitor-scientiam quaesitor-scientiam commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

What

Wires the HTTP/2 client connection (#27361) into the existing fetch path so a
single http.fetch call transparently speaks HTTP/2 when the server
supports it, and HTTP/1.1 otherwise. This is the user-facing unification of the
HTTP/2 work (ALPN #27343, HPACK #27353, frames #27356, client #27361), now that
all of its dependencies have merged.

resp := http.fetch(url: 'https://example.com/', enable_http2: true)!
assert resp.version() == .v2_0 // when the server offers h2

Changes

File Change
request.v, http.v add the opt-in enable_http2 field to Request and FetchConfig (and copy it through prepare)
backend.c.v advertise ALPN h2, http/1.1 when enabled; if the server selects h2, run over H2Conn (h2_do), else the existing HTTP/1.1 path unchanged
h2_client.v pure conversion glue: Request → H2 request and H2 response → Response
h2_client_test.v, h2_conn_test.v tests

Design

  • Opt-in, default off. enable_http2 defaults to false, so existing
    callers are completely unaffected and no ALPN extension is sent. A follow-up
    can flip the default to "prefer h2" once this has proven out.
  • One ALPN branch, one conversion site. The only entry point is
    negotiated_alpn() == 'h2' in net_ssl_do; the HTTP/1.1 path is untouched.
  • Conversion lowercases header names, drops hop-by-hop headers
    (RFC 7540 §8.1.2.2), maps Host to the :authority pseudo-header, collapses
    cookies into one field, and decodes Content-Encoding on the response exactly
    like the HTTP/1.1 path. resp.version() reports 2.0.
  • No connection pooling or multiplexing yet (each fetch is one TLS connection +
    one H2 request); those are later follow-ups.

Tests

  • Pure unit tests for the request/response conversions (pseudo-headers, header
    lowercasing, hop-by-hop stripping, cookie collapsing, content-length,
    authority/port).
  • A full glue round-trip: RequestH2Conn over an in-memory transport →
    Response, with no socket.
  • An opt-in end-to-end test against a real h2 server, gated behind
    -d network so it does not add CI flakiness.

Verified end-to-end against https://www.google.com/ (HTTP/2.0, 200), with the
default path still selecting HTTP/1.1. -W -cstrict -cc clang passes; the full
vlib/net/http suite is green (1 Windows-only skip).

🤖 Generated with Claude Code

@quaesitor-scientiam

Copy link
Copy Markdown
Contributor Author

#codex review

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 (vlang#27361).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@quaesitor-scientiam quaesitor-scientiam changed the title net.http: negotiate HTTP/2 in fetch via ALPN (opt-in) [draft, depends on #27361] net.http: negotiate HTTP/2 in fetch via ALPN (opt-in) Jun 6, 2026
@quaesitor-scientiam quaesitor-scientiam marked this pull request as ready for review June 6, 2026 17:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c86f758630

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread vlib/net/http/h2_client.v
Comment thread vlib/net/http/backend.c.v
…etch

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>
@JalonSolov JalonSolov merged commit d311ade into vlang:master Jun 6, 2026
76 of 83 checks passed
JalonSolov pushed a commit that referenced this pull request Jun 7, 2026
* net.http: stream response callbacks and stop limits over HTTP/2 (closes #27368)

The HTTP/2 fetch path (#27362) buffered the entire response body, so requests
using on_progress / on_progress_body / stop_copying_limit / stop_receiving_limit
were forced onto HTTP/1.1. This adds real streaming support so they work on the
HTTP/2 path too.

- New H2ClientRequest fields: on_data (per-DATA-frame callback) and
  stop_copying_limit / stop_receiving_limit, mirroring the HTTP/1.1 semantics.
- H2Conn.read_response now tracks cumulative body bytes, reads Content-Length
  if present, fires on_data per DATA frame (including chunk, cumulative
  body_so_far, content-length, and status), respects stop_copying_limit
  (caps the response body while still firing callbacks and draining the
  stream), and respects stop_receiving_limit (breaks the read loop early).
- The h2_do shim in backend.c.v adapts the Request's on_progress and
  on_progress_body into a single H2DataFn closure and threads the two stop
  limits through. The previous gate (uses_response_streaming) is removed, and
  the enable_http2 docs note that on_progress fires per DATA payload on h2
  rather than per raw network read.

Tests over the in-memory transport assert: on_data fires per DATA frame with
cumulative body_so_far, Content-Length (when present), and status;
stop_copying_limit caps the response body while callbacks keep firing across
all chunks; stop_receiving_limit breaks the loop early. Verified end-to-end
against https://www.google.com/ — http.fetch(enable_http2: true,
on_progress_body: f) reports HTTP/2.0, status 200, and the on_progress_body
callback fires once per 16 KiB DATA frame with cumulative bytes matching the
final body length. Passes under -W -cstrict -cc clang.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* net.http: RST_STREAM(CANCEL) and mark H2Conn unusable on early termination

When stop_receiving_limit triggered, the response stream was left open without
sending RST_STREAM. On a reused H2Conn the peer's in-flight DATA frames for the
abandoned stream would still arrive, consuming the connection-level receive
window and risking starvation of subsequent requests.

Fix: when bailing early, send RST_STREAM with error code CANCEL on the request
stream (RFC 7540 Section 8.1.4 / 5.4.2) so the peer stops sending more DATA,
and set a new H2Conn.aborted flag so subsequent H2Conn.do() calls return a
clear error rather than proceeding on a half-drained connection.

Strengthens the stop_receiving_limit test to assert the client emitted
RST_STREAM(CANCEL) on the request stream and that a second do() on the same
connection errors out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Richard Wheeler <quaesitor.scientiam@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
dy-tea pushed a commit to wenxuanjun/v that referenced this pull request Jun 19, 2026
* 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 (vlang#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>
dy-tea pushed a commit to wenxuanjun/v that referenced this pull request Jun 19, 2026
…g#27369)

* net.http: stream response callbacks and stop limits over HTTP/2 (closes vlang#27368)

The HTTP/2 fetch path (vlang#27362) buffered the entire response body, so requests
using on_progress / on_progress_body / stop_copying_limit / stop_receiving_limit
were forced onto HTTP/1.1. This adds real streaming support so they work on the
HTTP/2 path too.

- New H2ClientRequest fields: on_data (per-DATA-frame callback) and
  stop_copying_limit / stop_receiving_limit, mirroring the HTTP/1.1 semantics.
- H2Conn.read_response now tracks cumulative body bytes, reads Content-Length
  if present, fires on_data per DATA frame (including chunk, cumulative
  body_so_far, content-length, and status), respects stop_copying_limit
  (caps the response body while still firing callbacks and draining the
  stream), and respects stop_receiving_limit (breaks the read loop early).
- The h2_do shim in backend.c.v adapts the Request's on_progress and
  on_progress_body into a single H2DataFn closure and threads the two stop
  limits through. The previous gate (uses_response_streaming) is removed, and
  the enable_http2 docs note that on_progress fires per DATA payload on h2
  rather than per raw network read.

Tests over the in-memory transport assert: on_data fires per DATA frame with
cumulative body_so_far, Content-Length (when present), and status;
stop_copying_limit caps the response body while callbacks keep firing across
all chunks; stop_receiving_limit breaks the loop early. Verified end-to-end
against https://www.google.com/ — http.fetch(enable_http2: true,
on_progress_body: f) reports HTTP/2.0, status 200, and the on_progress_body
callback fires once per 16 KiB DATA frame with cumulative bytes matching the
final body length. Passes under -W -cstrict -cc clang.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* net.http: RST_STREAM(CANCEL) and mark H2Conn unusable on early termination

When stop_receiving_limit triggered, the response stream was left open without
sending RST_STREAM. On a reused H2Conn the peer's in-flight DATA frames for the
abandoned stream would still arrive, consuming the connection-level receive
window and risking starvation of subsequent requests.

Fix: when bailing early, send RST_STREAM with error code CANCEL on the request
stream (RFC 7540 Section 8.1.4 / 5.4.2) so the peer stops sending more DATA,
and set a new H2Conn.aborted flag so subsequent H2Conn.do() calls return a
clear error rather than proceeding on a half-drained connection.

Strengthens the stop_receiving_limit test to assert the client emitted
RST_STREAM(CANCEL) on the request stream and that a second do() on the same
connection errors out.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Richard Wheeler <quaesitor.scientiam@gmail.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants