Findings from the 2026-09-01 HTTP/3 investigation (see GOTCHAS.md §3). ncrs
works around all of them in-tree (http_clients::DavClient stamps request
versions — commit 390b1eb; build_pair in lib.rs rebuilds the read
client's fail-fast guarantees with h3 knobs), so nothing here blocks us. But
two of these are genuine upstream defects that other reqwest users are silently
hitting, and both come with a small test that fails today and passes patched.
Target: seanmonstar/reqwest, affected version 0.13.4 (latest at time of
writing). HTTP/3 support is feature-gated: build and test with
RUSTFLAGS="--cfg reqwest_unstable" cargo test --features http3 --test http3Tests live in tests/http3.rs; support::server::Http3 is the in-repo h3
test server. Before opening a PR, search the issue tracker for
http3_prior_knowledge version and http3 keep_alive — link this analysis
either way.
ClientBuilder::http3_prior_knowledge() (src/async_impl/client.rs:1571) only
sets HttpVersionPref::Http3, which builds the QUIC connector. Request
routing (src/async_impl/client.rs:2639) picks the h3 client only when the
individual request carries http::Version::HTTP_3:
let in_flight = match version {
#[cfg(feature = "http3")]
http::Version::HTTP_3 if self.inner.h3_client.is_some() => { /* h3 */ }
_ => { /* hyper over TCP */ }
};A plain client.get(url).send() defaults to HTTP_11 and rides hyper over
TCP. Worse, with the h3 pref reqwest sets no ALPN on that TCP path
(client.rs:832-834, "h3 ALPN is not valid over TCP"), so the "HTTP/3 client"
negotiates HTTP/1.1 — not even h2. Nothing fails, nothing warns; the caller
believes they are on QUIC.
That reqwest's own tests/http3.rs adds .version(http::Version::HTTP_3) to
every request shows the contract exists — it is just documented nowhere on
http3_prior_knowledge(), whose name and docs ("Only use HTTP/3") promise the
opposite of what a bare request does.
Field evidence: ncrs shipped http3_prior_knowledge() clients from 2026-05-15
(ec801f1) to 2026-09-01 (390b1eb) — roughly 3.5 months — with every request
on HTTP/1.1, including the reachability probes whose "QUIC failed where HTTP/2
succeeded" comparisons were therefore two TCP probes racing a network blip.
#[tokio::test]
async fn http3_prior_knowledge_routes_requests_over_http3() {
let server = server::Http3::new().build(|_| async { http::Response::default() });
let url = format!("https://{}/text", server.addr());
let res = reqwest::Client::builder()
.http3_prior_knowledge()
.danger_accept_invalid_certs(true)
.build()
.expect("client builder")
.get(url)
// Deliberately NO .version(http::Version::HTTP_3): the builder said
// "only use HTTP/3", so a bare request must already be HTTP/3.
.send()
.await
.expect("request");
assert_eq!(res.version(), http::Version::HTTP_3);
}On 0.13.4 this fails at send() (the TCP request never reaches the UDP-only
test server); against a dual-stack server it fails the version assertion with
HTTP/1.1. With the patch below it passes.
Standalone repro outside reqwest's harness: ncrs_core/examples/h3probe.rs
against any h3-capable server — stock 0.13.4 client prints HTTP/1.1 200 OK,
--v3 (per-request version stamp) prints HTTP/3.0 200 OK.
Route by builder pref, not only by per-request version. Sketch:
// src/async_impl/client.rs — ClientRef gains:
// h3_prior_knowledge: bool, // set from HttpVersionPref::Http3 at build()
let in_flight = match version {
#[cfg(feature = "http3")]
http::Version::HTTP_3 if self.inner.h3_client.is_some() => { /* h3, as today */ }
// A prior-knowledge client has no TCP transport worth speaking of (no ALPN
// is configured for it), so bare requests must go over QUIC too. `version`
// here is hyper's HTTP_11 default, indistinguishable from an explicit h1
// request — acceptable: "prior knowledge" means there is nothing else.
#[cfg(feature = "http3")]
_ if self.inner.h3_prior_knowledge && self.inner.h3_client.is_some() => { /* h3 */ }
_ => { /* hyper over TCP */ }
};Fallback position if maintainers consider the routing behaviour intentional: a
docs patch on http3_prior_knowledge() stating that every request must set
.version(http::Version::HTTP_3), plus a debug_assert!/warn when a
prior-knowledge client sends a non-h3 request. Either outcome removes the trap.
quinn supports TransportConfig::keep_alive_interval (PING frames that keep
NAT/firewall mappings warm and detect a silently dead path before
max_idle_timeout). reqwest plumbs several TransportConfig knobs —
http3_max_idle_timeout, receive/send windows, BBR — but not keep-alive, and
quinn's default is None (quinn-proto config/transport.rs:381).
Consequence for long-lived clients: after a NAT rebind or uplink switch, a
pooled QUIC connection is locally "alive" but blackholed. Every request rides
it into a timeout until max_idle_timeout (default 30 s) finally kills it,
while a TCP pool self-heals in one round trip (RST + hyper's reused-connection
retry). Keep-alives close most of that asymmetry.
// src/async_impl/client.rs
// Config (~line 250): quic_keep_alive_interval: Option<Duration>,
// Default (~line 377): quic_keep_alive_interval: None,
/// Set the QUIC keep-alive interval (`quinn`'s
/// `TransportConfig::keep_alive_interval`). Defaults to disabled.
#[cfg(feature = "http3")]
pub fn http3_keep_alive_interval(mut self, value: Duration) -> ClientBuilder {
self.config.quic_keep_alive_interval = Some(value);
self
}
// build_h3_connector closure (~line 457): accept the option and apply it —
// if let Some(interval) = quic_keep_alive_interval {
// transport_config.keep_alive_interval(Some(interval));
// }
// and pass `config.quic_keep_alive_interval` at both call sites (~648, ~852).Discriminator: a server that accepts exactly one QUIC connection, then stops accepting. Without keep-alives the client's connection idles out and the second request needs a redial, which the server refuses; with keep-alives the original connection outlives the silence and the second request succeeds.
#[tokio::test]
async fn http3_keep_alive_outlives_idle_timeout() {
// support::server::Http3 would need an `accept_once()` mode (drop the
// endpoint's Incoming after the first connection); ~10 lines.
let server = server::Http3::new()
.accept_once()
.build(|_| async { http::Response::default() });
let url = format!("https://{}/text", server.addr());
let client = reqwest::Client::builder()
.http3_prior_knowledge()
.danger_accept_invalid_certs(true)
.http3_max_idle_timeout(Duration::from_millis(500))
.http3_keep_alive_interval(Duration::from_millis(100)) // the patch
.build()
.expect("client builder");
let go = |c: &reqwest::Client| c.get(&url).version(http::Version::HTTP_3).send();
go(&client).await.expect("first request");
tokio::time::sleep(Duration::from_secs(2)).await; // >> idle timeout
// Fails on 0.13.4 (connection idled out at 500ms; the redial is refused);
// passes with keep-alives holding the original connection open.
go(&client).await.expect("second request on the kept-alive connection");
}Running the same body with the keep-alive line removed documents the pre-patch behaviour (second request errors), i.e. the issue exists.
Note for ncrs: measurements on 2026-09-01 (h3probe) showed QUIC loss
recovery riding out a 9 s UDP blackhole on its own and clean pool redials
across the 30 s/90 s idle boundaries, so we do not currently carry a
[patch.crates-io] fork for this. File it upstream; adopt the knob for the
read client if the field shows silent-path-death windows in practice.
connect_timeoutnever reaches the QUIC connector — nothing insrc/async_impl/h3_client/consults it, so an h3 dial on a blackholed path is bounded only by per-request timeouts (or quinn's handshake/idle timeout). TCP dials fail inconnect_timeout; h3 dials should match.- The h3 pool ignores
pool_max_idle_per_host—src/async_impl/h3_client/pool.rskeeps one connection per authority regardless; a client built withpool_max_idle_per_host(0)(the "always connect fresh" pattern) silently pools over h3. Honouring0, or documenting the divergence, would do.
- Search reqwest issues/PRs for prior art on each item; link them.
- Fork
seanmonstar/reqwest, one branch per patch (http3-prior-knowledge-routing,http3-keep-alive). -
RUSTFLAGS="--cfg reqwest_unstable" cargo test --features http3 --test http3— new tests red on master, green with the patch. - PR description: link the failing test, quote the field evidence above (3.5 months of an h3-configured client speaking HTTP/1.1 unnoticed).
- After either patch lands: drop the corresponding workaround note from
GOTCHAS.md§3 and, for Patch 1, the stamp inDavClientcan become a no-op on that reqwest version.