Skip to content

http(h2): send the request again over HTTP/1.1 after HTTP_1_1_REQUIRED - #46259

Open
robobun wants to merge 3 commits into
mainfrom
robobun/e999e598/fetch-http1-fallback-on-reset
Open

robobun wants to merge 3 commits into
mainfrom
robobun/e999e598/fetch-http1-fallback-on-reset

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With --experimental-http2-fetch, fetch() rejects with HTTP2StreamReset or HTTP2GoAway when the server refuses a request with HTTP_1_1_REQUIRED (RFC 9113 section 7). Without the flag it returns 200. IIS and Apache httpd send this code.
  • dispatch_frame (src/http/h2_client/dispatch.rs:507) has no arm for the code. No user reported this: tests found it.

Fix

  • The h2 session marks a stream that the server ends with RST_STREAM(HTTP_1_1_REQUIRED) or such a GOAWAY. A GOAWAY that names the stream marks only an idempotent request. Without a response, HTTPClient::retry_over_http1 pins the request to HTTP/1.1 and restarts it.
  • A request with protocol: "http2" or a stream body behaves as on main.
  • Correct because can_offer_h2 refuses a pinned request. curl and Chromium also resend after RST_STREAM. Node 26.3 rejects.
  • Verified: 19 tests in test/js/web/fetch/fetch-http2-client.test.ts, 14 fail on main. Self-reviewed: 9 concerns raised, 7 addressed (Notes).

Background

  • RST_STREAM ends one stream (one request). GOAWAY ends the connection and names the last stream that the server may have processed.
  • The pin is flags.forced_protocol, the field behind protocol. resolve_pending_h2 writes it when ALPN selects HTTP/1.1.
  • Considered per-origin memory, as in Chromium: it needs a new table.

Downsides

  • The resend leaves an HTTP/1.1 connection in the keep-alive pool. A later request can take it, not h2. Is that acceptable?
  • The pin lasts for the whole request, redirects included, as in curl.
  • Size: +252 bytes in 6 functions. A request with no reset runs the same instructions.
Notes

What the change covers

The server sends main this PR
RST_STREAM(HTTP_1_1_REQUIRED) before a response rejects HTTP2StreamReset 1 h2 stream, then 1 HTTP/1.1 request
the same, after a 100 Continue and the whole request body rejects HTTP2StreamReset the body is sent again over HTTP/1.1
GOAWAY(HTTP_1_1_REQUIRED) with a last stream id below the stream rejects HTTP2GoAway 1 h2 stream, then 1 HTTP/1.1 request
GOAWAY(HTTP_1_1_REQUIRED) that names the stream of a GET rejects HTTP2GoAway 1 h2 stream, then 1 HTTP/1.1 request
RST_STREAM(HTTP_1_1_REQUIRED) and a graceful GOAWAY in one read 6 h2 connections, then rejects HTTP2RefusedStream 1 h2 connection, then 1 HTTP/1.1 request
GOAWAY(HTTP_1_1_REQUIRED), last stream id 0 or 1, while 3 more requests wait for SETTINGS 4 h2 connections, 4 requests reject HTTP2GoAway 1 h2 connection, 4 HTTP/1.1 requests

A sibling stream on the same connection is not touched. It completes over h2.

What still rejects

  • A request with protocol: "http2". It has no HTTP/1.1 to go to. With RST_STREAM plus a graceful GOAWAY it goes to the next h2 connection, as on main.
  • A request whose body is a ReadableStream. The body can be consumed already.
  • A reset that arrives after the response headers.
  • A request that is not idempotent (POST, PATCH) when GOAWAY(HTTP_1_1_REQUIRED) names its stream: HTTP2GoAway, as on main. RFC 9113 section 6.8 says that the server may have processed a stream at or below the last stream id. An idempotent request (Method::is_idempotent) goes again, the rule that on_close in src/http/lib.rs uses for a connection that closes. session.goaway(NGHTTP2_HTTP_1_1_REQUIRED) of node:http2 sends this frame: Node 26.3.0 names the stream that the server got. Chromium sends such a request again for every method.
  • HTTP/3 is not in this PR. A stream that ends with H3_VERSION_FALLBACK (RFC 9114 section 8.1) still rejects with HTTP3StreamReset, and on_conn_close does not read the code of a CONNECTION_CLOSE. The branch robobun/e999e598/h3-version-fallback-wip has an implementation with 9 tests. It conflicts with http(h3): use TCP when the QUIC connection to an Alt-Svc alternative cannot be established #45539 and http(h3): do not replay a non-idempotent request that was already sent #42579 and belongs on top of them. It also needs a fix in us_quic_on_reset first: that function answers a RESET_STREAM from the peer with lsquic_stream_close, which ends an upload in progress with a FIN.

What other clients do

The server sends curl 8.14.1 Chromium Node 26.3.0 fetch
RST_STREAM(HTTP_1_1_REQUIRED) sends again over HTTP/1.1 sends again over HTTP/1.1 rejects (ERR_HTTP2_STREAM_ERROR)
GOAWAY(HTTP_1_1_REQUIRED), stream above the last id 6 h2 connections, then exit 56 sends again over HTTP/1.1 rejects (ERR_HTTP2_SESSION_ERROR)
GOAWAY(HTTP_1_1_REQUIRED) that names the stream exit 16 sends again over HTTP/1.1 rejects (ERR_HTTP2_SESSION_ERROR)
  • curl and Node: I ran them against a raw TLS server that sends these frames.
  • curl keeps HTTP/1.1 for the rest of the transfer, redirects included: lib/multi.c:1858-1877 sets data->state.http_neg, and Curl_pretransfer is the only place that resets it.
  • Chromium: from the source, not run. SpdySession::OnRstStream and SpdySession::OnGoAway in net/spdy/spdy_session.cc drain the whole session with ERR_HTTP_1_1_REQUIRED, and DoDrainSession calls SetHTTP11Required for the origin. HttpNetworkTransaction::HandleHttp11Required sends the request again.
  • Senders: the IIS page "HTTP/2 on IIS" says that Windows authentication is not supported with HTTP/2 and that Windows can fall back by "sending an error code to the client instructing it to retry over an HTTP/1.1 connection". Apache httpd sets H2_ERR_HTTP_1_1_REQUIRED when a request needs a TLS renegotiation (modules/http2/h2_headers.c).

Costs, measured

Release builds of 9b4284f with and without this change:

  • 6 function symbols change size, +252 bytes in total: dispatch::parse_frames +159, ClientSession::on_data +52, ClientSession::drain_pending +22, HTTPClient::resolve_pending_h2 +16, HTTPClient::first_call::<true> +2, ClientSession::maybe_release +1. No other symbol changes size. The .text section grows by 256 bytes (81,904,485 to 81,904,741), and the stripped binary by one 4 KiB page (107,493,088 to 107,497,184).
  • Struct sizes do not change: h2 Stream 184 bytes, h2 ClientSession 464, HTTPClient 1680, Flags 20.
  • One h2 GET that gets a 200 runs 2,087 instructions in those 6 functions and ClientSession::attach, before and after (gdb stepi).
  • With the flag off, an HTTPS request runs 71 instructions in them, before and after.
  • The new instructions are in the RST_STREAM arm, the GOAWAY arm, the arm of deliver_stream for a failed stream, the arm of drain_pending for a request that finds no room, and the HTTP/1.1 arm of resolve_pending_h2.
  • A request that is not reset gets no new allocation, queue entry, or syscall. The resend uses restart_from_top, the body of the existing REFUSED_STREAM retry.

The connection pool after a resend

The tests "after RST_STREAM, the next request tries h2 first", "after GOAWAY, the next request takes the idle HTTP/1.1 connection" and "an HTTP/1.1 connection that became idle before the h2 connection carries the next request" hold the current behaviour:

  • After RST_STREAM, the h2 connection goes to the pool first. The next request takes it and tries h2 again.
  • After GOAWAY, the h2 connection is closed. The next request takes the idle HTTP/1.1 connection.
  • If the h2 connection becomes idle after the HTTP/1.1 connection, the next request takes the HTTP/1.1 connection. It is not offered h2.

The pool gives a request the first idle connection that can carry it (find_in in src/http/HTTPContext.rs). Before this PR only protocol: "http1.1" in user code could put such a connection in the pool while the flag is on.

Overlap with open PRs

Self-review

  • Addressed: the HTTP/3 half moved out of this PR. A request that is not eligible takes the path of main. A draft rejected it where main retries over h2. Tests for the next request after a resend, for a 1xx before the reset, and for a GOAWAY that names the stream. The test for waiting requests now waits for its precondition. The comparison with curl, Chromium and Node is per frame.
  • Rejected: one retry entry keyed by the reason. It rewrites the REFUSED_STREAM arm and changes no behaviour.
  • Rejected: a note about requests with an Upgrade header over h2. It is a different mechanism and not part of this change.

Tests

  • test/js/web/fetch/fetch-http2-client.test.ts: 107 pass on the debug build, 7 runs of 7. Of the 19 new tests, 14 fail and 5 pass on a release build of main. The 5 hold behaviour that does not change.
  • Also run on the debug build: fetch-http2-adversarial.test.ts (30), fetch-session.test.ts (34), fetch-http3-client.test.ts (67), test/js/bun/http/serve-http2.test.ts (93).
  • The cap on subprocesses of the file moved from spawnCapped into beforeEach and afterEach. Under ASAN the file lets 4 run at a time, and a test waited for its slot inside its own timeout: up to 2.6 s of the 5 s on a 12-core machine, where 2 of 4 runs of the file from main failed, each time in another test. The wait is now in the hook, so the new block can be describe.concurrent too.

[human-review] gate passed · iteration 1 · 7 files touched

fails on main (without fix)
ASAN without fix: 14 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.4 (367d939d9)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [2444.35ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [2613.25ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [3523.39ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [4586.29ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [4395.20ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [2515.07ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > ReadableStream body that does not match its declared content-length 
... (truncated)

release without fix: 5 skipped
bun test v1.4.4-canary.1 (392f82e29)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > ReadableStream body that does not match its declared content-length rejects [483.71ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [671.02ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > cold-start: parallel requests coalesce onto one TLS connect [436.60ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [621.68ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [680.72ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [678.17ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [620.79ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [676.54ms]
(pass) fetch
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/js/web/fetch/fetch-http2-client.test.ts"
bun test v1.4.4 (367d939d9)

test/js/web/fetch/fetch-http2-client.test.ts:
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > response body larger than one DATA frame [1438.42ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > GET: status, headers and body round-trip [2296.30ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > gzip content-encoding is decompressed [2933.16ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > concurrent requests multiplex on one h2 session [2787.04ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST: request body is delivered as DATA frames [3278.73ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > POST with ReadableStream body streams as raw DATA frames [1951.25ms]
(pass) fetch() over HTTP/2 (BUN_FEATURE_FLAG_EXPERIMENTAL_HTTP2_CLIENT) > 303 on a ReadableStream POST: the follow-up GET carries no framing h
... (truncated)

release with fix: 5 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 11806ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/42] gen generated_host_exports.rs
generated_host_exports.rs: 116 exports (host=5, lazy=4, generic=107, rust=0); 251 extern-C blocks audited
[2/41] rustc bun_http_types 
[3/41] rustc bun_s3_signing 
[4/41] rustc bun_uws_sys 
[5/41] rustc bun_uws 
[6/41] rustc bun_io 
[7/41] rustc bun_event_loop 
[8/41] rustc bun_crash_handler 
[9/41] rustc bun_sourcemap 
[10/41] rustc bun_spawn 
[11/41] rustc bun_patch 
[12/41] rustc bun_js_printer 
[13/41] rustc bun_http 
[14/41] rustc bun_ini 
[15/41] rustc bun_js_parser 
[16/41] rustc bun_resolver 
[17/41] rustc bun_router 
[18/41] rustc bun_css 
[19/41] rustc bun_bundler 
[20/41] rustc bun_transpiler 
[21/41] rustc bun_standalone_graph 
[22/41] rustc bun_bunfig 
[23/41] rustc bun_install 
[24/41] rustc bun_jsc 
[25/41] rustc bun_ast_jsc 
[26/41] rustc bun_sys_jsc 
[27/41] rustc bun_semver_jsc 
[28/41] rustc bun_bundler_jsc 
[29/41] rustc bun_patch_jsc 
[30/41] rustc bun_css_jsc 
[31/41] rustc bun_js_parser_jsc 
[32/41] rustc bun_sourcemap_jsc 
[33/41] rustc bun_ins
... (truncated)
diff hotspot
packages/bun-types/globals.d.ts              |   5 +
 src/http/h2_client/ClientSession.rs          |  11 +
 src/http/h2_client/Stream.rs                 |   3 +
 src/http/h2_client/dispatch.rs               |   8 +
 src/http/lib.rs                              |  30 ++-
 src/http_types/h2.rs                         |   1 +
 test/js/web/fetch/fetch-http2-client.test.ts | 355 ++++++++++++++++++++++++---
 7 files changed, 383 insertions(+), 30 deletions(-)

gate history · 1 passed · 1 rejected · iteration 1

evidence per changed file
file                                          reads  edits  tests
packages/bun-types/globals.d.ts                   2      3     83
src/http/h2_client/ClientSession.rs               3      2     83
src/http/h2_client/Stream.rs                      1      1     83
src/http/h2_client/dispatch.rs                    2      2     83
src/http/lib.rs                                   6      3     84
src/http_types/h2.rs                              1      1     83
test/js/web/fetch/fetch-http2-client.test.ts      2      0     83

A server can refuse a request over HTTP/2 with HTTP_1_1_REQUIRED
(RFC 9113 section 7). The fetch client rejected such a request.

The h2 session now marks a stream that gets RST_STREAM with the code,
or that is above the last stream id of a GOAWAY with the code. When
the stream has no response yet, the request is pinned to HTTP/1.1 and
starts again. A request that names its protocol, or whose body is a
stream, behaves as before.
@robobun
robobun requested a review from alii as a code owner October 21, 2026 00:47
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: the change is up. It waits for CI and review.

How it was reproduced: a raw TLS server offers h2 and http/1.1 and answers each HTTP/2 request with RST_STREAM(HTTP_1_1_REQUIRED). With --experimental-http2-fetch, fetch() rejects with HTTP2StreamReset on main (9b4284f). With this change it returns the HTTP/1.1 response. The tests in describe("HTTP_1_1_REQUIRED") of test/js/web/fetch/fetch-http2-client.test.ts use the same server: 14 of the 19 fail on main.

PR: #46259

@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 11 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: ab186284-becc-417a-a662-edc94e8fcd5c
📥 Commits

Reviewing files that changed from the base of the PR and between 9b4284f and 392f82e.

📒 Files selected for processing: 7
  • packages/bun-types/globals.d.ts
  • src/http/h2_client/ClientSession.rs
  • src/http/h2_client/Stream.rs
  • src/http/h2_client/dispatch.rs
  • src/http/lib.rs
  • src/http_types/h2.rs
  • test/js/web/fetch/fetch-http2-client.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:58 AM PT - Oct 21st, 2026

❌ @robobun, your commit 392f82e has 5 failures in Build #129877 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46259

That installs a local version of the PR into your bun-46259 executable, so you can run:

bun-46259 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Under ASAN the file lets 4 subprocesses run at a time, and a test waits
for its slot while its timeout runs. On a 12-core machine a test waits
up to 2.6 s, and 2 of 4 runs of the file failed, each time with another
test at the 5 s default.
Comment thread src/http/h2_client/ClientSession.rs Outdated
Comment thread src/http/h2_client/Stream.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated
Comment thread src/http/lib.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/http/h2_client/dispatch.rs — pre-existing: a request still unanswered when the server's GOAWAY(HTTP_1_1_REQUIRED) names its stream keeps rejecting with HTTP2GoAway, so the fallback never fires for a Node server's session.goaway(NGHTTP2_HTTP_1_1_REQUIRED), which names the stream it received. dispatch.rs:549-554 sets only fatal_error for streams at or below last_stream_id, never http1_required, so ClientSession.rs:1218 does not retry. Fix: mark those streams too when status_code == 0, limited to idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE) if the replay concern in the PR notes stands; Chromium retries this frame shape for every method.

    Why this was flagged

    Trigger: the server sends GOAWAY with error code 0xd and last_stream_id equal to or above the open stream, before any response HEADERS; session.goaway(http2.constants.NGHTTP2_HTTP_1_1_REQUIRED) in node:http2 (the only API a Node server has for this code) sends exactly that, and the PR's own test 'GOAWAY that names the stream: the request rejects' codifies it. In dispatch.rs:539-555 the s.id > last_id arm sets s.http1_required; the else if !graceful && !s.remote_closed() arm at 549-554 sets s.fatal_error = Some(crate::Error::HTTP2GoAway) only. In deliver_stream the new check at ClientSession.rs:1218 requires stream.http1_required, so the request goes to h2_fail(err) and fetch() rejects with HTTP2GoAway, exactly as on the base branch, although the server asked for HTTP/1.1 and can_retry_over_http1() is true. The PR notes name this as left open pending a decision on replaying non-idempotent requests; restricting the retry to idempotent methods or to streams whose request body was never sent removes that concern for the common GET case.

    Verification: Pre-existing; acknowledged in diff. The trigger is a server GOAWAY(0xd) with last_stream_id >= the open stream before any response HEADERS. In src/http/h2_client/dispatch.rs:549-554 the else arm sets only s.fatal_error = Some(crate::Error::HTTP2GoAway) and never touches http1_required, so ClientSession.rs:1218 does not retry and fetch rejects with HTTP2GoAway; the base takes the same path.

Comment thread src/http/lib.rs
debug_assert!(self.h2.is_none());
debug_assert!(self.can_retry_over_http1());
self.pin_to_http1();
self.restart_from_top();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 (optional) Users running with the h2 flag can lose HTTP/2 for an origin after a single HTTP_1_1_REQUIRED fallback, for as long as traffic keeps the fallback socket alive. The resent request parks its HTTP/1.1 socket in the keep-alive pool on completion. find_in (HTTPContext.rs:869-875) then hands that socket to any later request that offers H1OrH2, and each use re-parks it. Fix: keep a socket opened by retry_over_http1 out of the pool, or make the pool prefer an idle h2 session over an h1 socket for H1OrH2 requests, so only requests pinned to h1 take it. The PR asks whether this is acceptable; with the 5-minute idle default (HTTPContext.rs:781) and steady traffic the socket never expires.

Why this was flagged

A server answers one request with RST_STREAM(HTTP_1_1_REQUIRED) while the h2 flag is on. ClientSession.rs:1218-1219 calls retry_over_http1, lib.rs:2199-2204, which pins the request and restarts it over a new HTTP/1.1 TLS connection; on completion that socket is released to the keep-alive pool like any h1 socket. A later request without protocol calls alpn_offer() -> H1OrH2; connect (HTTPContext.rs:1124) asks existing_socket, and find_in returns the first slot that passes HTTPContext.rs:869-875, where an h1 socket is accepted for H1OrH2. Each such use re-parks the socket and re-arms the 5-minute timer (HTTPContext.rs:779-783), so under continuous traffic the origin stays on HTTP/1.1 for one request at a time, with no multiplexing, until the socket is idle for 5 minutes. On the base branch only an explicit protocol: "http1.1" could put an h1 socket in the pool while the flag is on, so this did not occur for ordinary requests. The PR description flags this and asks if it is acceptable; no code bounds it.

Verification: The PR description's "Downsides" names it and the new test "an HTTP/1.1 connection that became idle before the h2 connection carries the next request" (test/js/web/fetch/fetch-http2-client.test.ts:2980-3009) pins it: /next is answered "200 over h1" although an idle h2 session is in the pool. find_in (HTTPContext.rs:867-875) accepts the h1 socket for any H1OrH2 caller.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I left this as it is. The question is in the Downsides of the description, for a maintainer to decide.

  • A request takes the idle HTTP/1.1 connection only when no h2 connection is in use. connect in HTTPContext.rs checks active_h2_sessions before the pool, so concurrent requests still join an active h2 connection.
  • The two fixes have opposite costs. If the pool prefers h2, an origin that refuses every request with HTTP_1_1_REQUIRED pays one refused h2 attempt for each request. If the connection of the resend stays out of the pool, each refused request pays a new TLS handshake.
  • Chromium goes the other way. After one HTTP_1_1_REQUIRED it uses HTTP/1.1 for the origin (SetHTTP11Required). That needs a table per origin, which is a separate change.

If a maintainer wants the pool to keep a connection that never offered h2 away from a request that offers it, that is one flag on the pooled socket and one check in find_in. I can add it here.

Comment thread test/js/web/fetch/fetch-http2-client.test.ts Outdated
Comment thread test/js/web/fetch/fetch-http2-client.test.ts Outdated
…ream

A GOAWAY(HTTP_1_1_REQUIRED) whose last stream id is at or above the
stream says that the peer may have processed the request. An
idempotent request is now sent again over HTTP/1.1. Another request
rejects as before.

Tests: a test takes its subprocess slot in a beforeEach hook. The wait
no longer counts against the timeout of the test, so the new block can
be concurrent and the file needs no longer default timeout under ASAN.

Each new comment in src/ is one line.
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

About the GOAWAY(HTTP_1_1_REQUIRED) that names the stream: 392f82e covers it for an idempotent request. The GOAWAY arm in dispatch.rs now marks such a stream when Method::is_idempotent holds, and the request goes again over HTTP/1.1. A request that is not idempotent still rejects with HTTP2GoAway, because the server may have processed it (RFC 9113 section 6.8). The tests "GOAWAY that names the stream: the request is sent again over HTTP/1.1" and "GOAWAY that names the stream: a POST rejects" hold both sides.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the latest push and didn't find any bugs; the two test nits from my last pass (the ASAN-wide setDefaultTimeout and the serial describe) are addressed by moving the slot wait into a beforeEach hook. Because this commit also widens the replay to requests the peer may already have processed, and the .d.ts change is in a CODEOWNER-owned path, a human look is still worthwhile.

What was reviewed:

  • The new GOAWAY arm in dispatch.rs that resends a request whose stream is at or below last_stream_id: it is gated on Method::is_idempotent (GET/HEAD/PUT/DELETE/OPTIONS/TRACE only), and the POST variant of that test still asserts the HTTP2GoAway rejection.
  • The status_code == 0 gate in deliver_stream against a 100 Continue before the reset: 1xx HEADERS leave status_code at 0, so the resend path is reached as the test expects.
  • The restart_from_top factoring: h2_retries += 1 stays only in retry_from_h2, and pin_to_http1 is a no-op once forced_protocol is set, so protocol: "http2" is not overridden for coalesced waiters.
Extended reasoning...

The diff adds HTTP/1.1 fallback to the experimental HTTP/2 fetch client: a new ErrorCode constant, per-stream and per-session http1_required flags set from RST_STREAM and GOAWAY in dispatch.rs, a retry branch in deliver_stream, and can_retry_over_http1/retry_over_http1/pin_to_http1/restart_from_top in lib.rs, plus a JSDoc note and about 350 lines of tests. It touches no auth, crypto or injection surface; the sensitive part is request replay over a different protocol, which the latest commit extends to idempotent requests whose stream the GOAWAY names. The source change is small and I verified the helpers it relies on, but packages/bun-types/globals.d.ts is covered by .github/CODEOWNERS, and the author's open question about the HTTP/1.1 socket left in the keep-alive pool is a design call, so defer rather than approve.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant