From 6d0f2a08a5f3059d3ddcefb98fae49adbc868351 Mon Sep 17 00:00:00 2001 From: Marcin Rataj Date: Wed, 17 Jun 2026 23:48:50 +0200 Subject: [PATCH] feat(httpnet): opt-in HTTP/3 retrieval Add WithHTTP3() to opportunistically upgrade HTTP block retrieval to HTTP/3 for hosts that advertise it via Alt-Svc, persisting the verdict in the peerstore and falling back to HTTP/2 (with a per-host back-off) when QUIC is unavailable. Off by default. The httpnet status metric gains a version label (h2/h3) so HTTP/2 and HTTP/3 traffic can be compared. --- CHANGELOG.md | 2 + bitswap/network/httpnet/http3.go | 313 ++++++++++++++++++++++++++ bitswap/network/httpnet/http3_test.go | 190 ++++++++++++++++ bitswap/network/httpnet/httpnet.go | 45 +++- bitswap/network/httpnet/metrics.go | 26 ++- bitswap/network/httpnet/msg_sender.go | 4 +- go.mod | 2 +- 7 files changed, 570 insertions(+), 12 deletions(-) create mode 100644 bitswap/network/httpnet/http3.go create mode 100644 bitswap/network/httpnet/http3_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 19f182683..816241a21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ The following emojis are used to highlight certain changes: ### Added +- ✨ `bitswap/network/httpnet`: experimental, opt-in HTTP/3 (QUIC) support for trustless HTTP retrieval via the new `WithHTTP3()` option (off by default). Providers keep announcing their existing `/tcp/443/tls/http` address; with the option enabled the client opportunistically upgrades to HTTP/3 on hosts that advertise it through `Alt-Svc`, remembers the result per peer so it survives reconnects, and falls back to HTTP/2 whenever QUIC is unavailable (for example when outbound UDP is blocked). HTTP/3 needs outbound UDP, and operators may need to raise UDP buffer limits (`net.core.rmem_max`/`wmem_max` on Linux). The `status` metric gains a `version` label (`h2`/`h3`) so HTTP/2 and HTTP/3 request volume and status codes can be compared. This is an early prototype whose behavior may still change; early feedback on how it performs against real providers is very welcome at https://github.com/ipfs/boxo/issues. + ### Changed ### Removed diff --git a/bitswap/network/httpnet/http3.go b/bitswap/network/httpnet/http3.go new file mode 100644 index 000000000..0a68ab515 --- /dev/null +++ b/bitswap/network/httpnet/http3.go @@ -0,0 +1,313 @@ +package httpnet + +import ( + "crypto/tls" + "net/http" + "strings" + "sync" + "time" + + "github.com/ipfs/boxo/bitswap/network" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/peerstore" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" +) + +// http3proto is the value of http.Response.Proto for responses received over +// HTTP/3, as set by the quic-go http3 transport. It mirrors http2proto. +const http3proto = "HTTP/3.0" + +// peerstoreHTTP3Key is the peerstore key under which we remember whether a +// peer's HTTP endpoint supports HTTP/3. Stored per peer like +// peerstoreSupportsHeadKey, it lets HTTP/3 support survive reconnects, and +// process restarts when the peerstore is backed by a persistent datastore. +const peerstoreHTTP3Key = "http-retrieval-http3-support" + +// Defaults for the HTTP/3 transport. +const ( + // DefaultH3HandshakeTimeout bounds how long we wait for a QUIC handshake + // before giving up on HTTP/3 for a request and falling back to HTTP/2. Two + // reasons it is short, and shorter than the TCP dial timeout + // (DefaultDialTimeout): + // + // - It is a fallback trigger, not a terminal give-up. A failed TCP dial + // means the peer is unreachable; a failed QUIC handshake only means + // UDP/QUIC is not working here, and we immediately retry over HTTP/2, + // which we already know the host speaks. + // - The HTTP/3 attempt and the HTTP/2 fallback share one request + // deadline (the MessageSender SendTimeout). Time spent waiting for QUIC + // is time the fallback no longer has, so we keep the HTTP/3 budget + // small to leave the fallback room to succeed. + DefaultH3HandshakeTimeout = 2 * time.Second + + // DefaultH3FailureBackoff is how long we avoid retrying HTTP/3 against a + // host after a failed QUIC attempt, so we do not re-pay the handshake + // timeout on every request to a host we cannot reach over UDP. + // + // Back-off is per host on purpose. HTTP/3 can fail for unrelated reasons + // (a host's HTTP/3 endpoint is down, or the local network blocks UDP), and + // a single global "HTTP/3 is broken" switch would let a few failing hosts + // disable HTTP/3 for hosts that work fine. If UDP is blocked everywhere, + // each host independently learns that once and falls back to HTTP/2; the + // cost is just one fast handshake timeout per host per back-off window. + DefaultH3FailureBackoff = 30 * time.Minute +) + +// WithHTTP3 enables opportunistic HTTP/3 (QUIC) retrieval. +// +// HTTP/3 cannot be negotiated by upgrading a live TCP connection: a client +// must learn out of band that a host serves HTTP/3, then open a separate QUIC +// connection. With this option enabled, the network discovers HTTP/3 support +// from the "Alt-Svc" header that servers send on their HTTP/2 responses (RFC +// 7838). No change to provider records or multiaddrs is required: endpoints +// keep announcing the usual /tcp/443/tls/http address, and we upgrade to +// HTTP/3 only when the server tells us it is available. +// +// The behaviour per host is: +// +// 1. The first request goes over HTTP/2 (the existing TCP transport). +// 2. If the response advertises "h3" via Alt-Svc, we remember it (and persist +// it in the peerstore so it survives reconnects). +// 3. Later requests to that host try HTTP/3 first. +// 4. If the QUIC attempt fails (UDP blocked, no HTTP/3 listener, handshake +// timeout), we fall back to HTTP/2 and back off from HTTP/3 for that host +// before trying again. +// +// Caveats: +// +// - HTTP/3 needs outbound UDP. In networks that block UDP/443 every request +// falls back to HTTP/2 after a short handshake timeout, so correctness is +// unaffected but no speed-up is gained. +// - The quic-go transport does not honor HTTP proxy settings +// (http.ProxyFromEnvironment); requests that need a proxy should rely on +// the HTTP/2 path. QUIC cannot traverse an HTTP CONNECT proxy. +// - Operators may need to raise the UDP receive buffer limit +// (net.core.rmem_max / wmem_max on Linux) to avoid a quic-go warning about +// an undersized buffer. +// +// HTTP/3 is opt-in and off by default. +func WithHTTP3() Option { + return func(net *Network) { + net.enableHTTP3 = true + } +} + +// h3Fallback is an http.RoundTripper that prefers HTTP/3 for hosts known to +// support it and transparently falls back to a TCP transport (HTTP/1.1 or +// HTTP/2) otherwise. It learns HTTP/3 support from Alt-Svc response headers. +// +// HTTP/3 is only attempted for https requests with no body, so a failed +// attempt can always be retried over HTTP/2 with the same request (quic-go +// closes a non-nil body while sending). Block retrieval always uses nil-body +// GET/HEAD requests. +type h3Fallback struct { + tcp http.RoundTripper // HTTP/1.1 + HTTP/2 over TCP (the default transport) + quic *http3.Transport // HTTP/3 over QUIC + caps *h3Capabilities +} + +// newH3Fallback builds an h3Fallback wrapping the given TCP transport. The TLS +// config is cloned so the HTTP/3 transport cannot mutate the one used by the +// TCP transport; only the settings we care about (such as InsecureSkipVerify) +// carry over. The http3 transport sets the "h3" ALPN itself. +func newH3Fallback(tcp http.RoundTripper, tlsClientConfig *tls.Config, idleTimeout time.Duration) *h3Fallback { + return &h3Fallback{ + tcp: tcp, + quic: &http3.Transport{ + TLSClientConfig: tlsClientConfig.Clone(), + QUICConfig: &quic.Config{ + HandshakeIdleTimeout: DefaultH3HandshakeTimeout, + // Keep QUIC connections long-lived: a generous idle timeout plus + // keep-alive pings maintain one connection per host instead of + // reopening it. Besides saving handshakes, this avoids a churn + // penalty seen against some servers (e.g. Cloudflare), where + // rapidly closing and reopening a QUIC connection from the same + // source triggers a Retry / address-validation round trip (or a + // probe timeout), making a reconnect much slower than a fresh TCP + // one. Long-lived connections never hit this. + MaxIdleTimeout: idleTimeout, // matches the TCP transport's IdleConnTimeout + KeepAlivePeriod: connKeepAlive, // matches the TCP dialer's keep-alive + }, + }, + caps: newH3Capabilities(DefaultH3FailureBackoff), + } +} + +// RoundTrip implements http.RoundTripper. +func (h *h3Fallback) RoundTrip(req *http.Request) (*http.Response, error) { + authority := req.URL.Host + + // Only attempt HTTP/3 when it can apply and we can safely fall back: + // - https only: QUIC is always TLS, so plaintext http endpoints (LAN) + // can never speak HTTP/3. + // - nil body: a failed attempt is retried over HTTP/2 with the same + // request, and quic-go closes a non-nil body while sending. + if req.URL.Scheme == "https" && req.Body == nil && h.caps.preferH3(authority) { + resp, err := h.quic.RoundTrip(req) + if err == nil { + // Keep learning from Alt-Svc on the HTTP/3 path too, so a server + // withdrawing HTTP/3 (Alt-Svc: clear) is noticed promptly. + h.caps.learnFromAltSvc(authority, resp.Header.Get("Alt-Svc")) + return resp, nil + } + + // A non-nil error from RoundTrip means we could not obtain an HTTP + // response over QUIC at all (UDP blocked, no HTTP/3 listener, or the + // handshake timed out). A real HTTP error status such as 404 is + // returned as a *response*, not an error, so reaching here always + // signals a connectivity problem worth falling back from. + // + // The exception is a cancelled request: HTTP/2 would fail the same + // way, so we return the original error rather than retrying. + if req.Context().Err() != nil { + return nil, err + } + h.caps.markFailed(authority) + log.Debugf("HTTP/3 request to %s failed (%s); falling back to HTTP/2", authority, err) + } + + resp, err := h.tcp.RoundTrip(req) + if err == nil { + h.caps.learnFromAltSvc(authority, resp.Header.Get("Alt-Svc")) + } + return resp, err +} + +// Close releases the QUIC transport and its UDP socket. +func (h *h3Fallback) Close() error { + return h.quic.Close() +} + +// seedFromPeerstore primes the in-memory cache from a previously persisted +// HTTP/3 verdict, so the first request after a reconnect (or restart, with a +// persistent peerstore) can prefer HTTP/3 without re-learning it from an +// Alt-Svc header. A stale hint is self-correcting: RoundTrip falls back to +// HTTP/2 and backs off if HTTP/3 turns out to be unavailable. +func (h *h3Fallback) seedFromPeerstore(ps peerstore.Peerstore, p peer.ID, urls []network.ParsedURL) { + v, err := ps.Get(p, peerstoreHTTP3Key) + if err != nil { + return // nothing remembered + } + if supported, ok := v.(bool); !ok || !supported { + return + } + for _, u := range urls { + h.caps.seedSupported(u.URL.Host) + } +} + +// persistToPeerstore records, per peer, whether any of its endpoints is +// currently preferred over HTTP/3, mirroring how HEAD support is stored. +// +// The verdict is one bool per peer, not per endpoint. On a multi-endpoint peer +// where only some speak HTTP/3, seedFromPeerstore marks all of them capable; +// the non-h3 ones self-correct on their first failed attempt (one handshake +// timeout, then per-host back-off). Peers normally have a single HTTP endpoint, +// so this is rarely observable. +func (h *h3Fallback) persistToPeerstore(ps peerstore.Peerstore, p peer.ID, urls []network.ParsedURL) { + supported := false + for _, u := range urls { + if h.caps.preferH3(u.URL.Host) { + supported = true + break + } + } + _ = ps.Put(p, peerstoreHTTP3Key, supported) +} + +// h3Capabilities remembers, per authority (host:port), whether a server is +// known to support HTTP/3 and whether a recent HTTP/3 attempt failed. It is +// safe for concurrent use. +type h3Capabilities struct { + mu sync.Mutex + hosts map[string]h3Status + backoff time.Duration + now func() time.Time // overridable in tests +} + +type h3Status struct { + // supported is set when the server advertised "h3" via Alt-Svc (or when + // seeded from the peerstore). + supported bool + // retryAfter, when non-zero, blocks HTTP/3 attempts to this host until that + // time. It is set after a failed QUIC attempt so we do not pay the + // handshake timeout on every request to a host we cannot reach over UDP. + retryAfter time.Time +} + +func newH3Capabilities(backoff time.Duration) *h3Capabilities { + return &h3Capabilities{ + hosts: make(map[string]h3Status), + backoff: backoff, + now: time.Now, + } +} + +// preferH3 reports whether we should try HTTP/3 for the given authority: the +// server is known to support it and we are not in a back-off window. +func (c *h3Capabilities) preferH3(authority string) bool { + c.mu.Lock() + defer c.mu.Unlock() + s := c.hosts[authority] + if !s.supported { + return false + } + return s.retryAfter.IsZero() || c.now().After(s.retryAfter) +} + +// learnFromAltSvc records HTTP/3 support from an Alt-Svc response header. An +// empty header is ignored, since servers do not repeat Alt-Svc on every +// response and we must not forget what we already learned. +func (c *h3Capabilities) learnFromAltSvc(authority, altSvc string) { + if altSvc == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + s := c.hosts[authority] + s.supported = altSvcAdvertisesH3(altSvc) + c.hosts[authority] = s +} + +// seedSupported marks an authority as HTTP/3-capable, but only when we have no +// fresher in-process knowledge about it. Used to prime the cache from the +// peerstore on a cold start, without overriding what we learned this run. +func (c *h3Capabilities) seedSupported(authority string) { + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.hosts[authority]; ok { + return + } + c.hosts[authority] = h3Status{supported: true} +} + +// markFailed records that an HTTP/3 attempt to authority failed and starts a +// per-host back-off before HTTP/3 is tried against it again. +func (c *h3Capabilities) markFailed(authority string) { + c.mu.Lock() + defer c.mu.Unlock() + s := c.hosts[authority] + s.retryAfter = c.now().Add(c.backoff) + c.hosts[authority] = s +} + +// altSvcAdvertisesH3 reports whether an Alt-Svc header value advertises +// HTTP/3. The header is a comma-separated list of alternative services, each +// written as `protocol-id="host:port"; param=value` (RFC 7838); we only need +// to know whether the "h3" protocol id is present. The special value "clear" +// withdraws all alternatives and so reports false. Obsolete draft ids such as +// "h3-29" are intentionally not treated as HTTP/3. +func altSvcAdvertisesH3(altSvc string) bool { + for alt := range strings.SplitSeq(altSvc, ",") { + alt = strings.TrimSpace(alt) + if alt == "clear" { + return false // server withdrew all alternatives + } + // The protocol id is everything up to the first '='. + if id, _, _ := strings.Cut(alt, "="); id == "h3" { + return true + } + } + return false +} diff --git a/bitswap/network/httpnet/http3_test.go b/bitswap/network/httpnet/http3_test.go new file mode 100644 index 000000000..72edac027 --- /dev/null +++ b/bitswap/network/httpnet/http3_test.go @@ -0,0 +1,190 @@ +package httpnet + +import ( + "crypto/tls" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/ipfs/boxo/bitswap/network" + "github.com/libp2p/go-libp2p/core/test" + "github.com/libp2p/go-libp2p/p2p/host/peerstore/pstoremem" + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/stretchr/testify/require" +) + +func TestAltSvcAdvertisesH3(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {`h3=":443"; ma=86400`, true}, + {`h3=":443"; ma=86400, h2=":443"`, true}, + {`h2=":443", h3=":443"`, true}, + {` h3=":443"`, true}, + {`h3`, true}, // bare protocol id + {`h2=":443"`, false}, + {`h3-29=":443"`, false}, // obsolete draft, not HTTP/3 + {``, false}, + {`clear`, false}, + {`clear, h3=":443"`, false}, // clear withdraws all alternatives + {`quic=":443"`, false}, + } + for _, tc := range cases { + require.Equalf(t, tc.want, altSvcAdvertisesH3(tc.in), "input %q", tc.in) + } +} + +func TestH3Capabilities(t *testing.T) { + now := time.Unix(1000, 0) + c := newH3Capabilities(10 * time.Minute) + c.now = func() time.Time { return now } + + require.False(t, c.preferH3("host"), "unknown host should not prefer h3") + + c.learnFromAltSvc("host", `h3=":443"; ma=86400`) + require.True(t, c.preferH3("host"), "should prefer h3 after learning support") + + c.markFailed("host") + require.False(t, c.preferH3("host"), "should not prefer h3 during back-off") + + now = now.Add(11 * time.Minute) + require.True(t, c.preferH3("host"), "should prefer h3 again after back-off expires") + + c.learnFromAltSvc("host", "") + require.True(t, c.preferH3("host"), "empty Alt-Svc must not change learned state") + + c.learnFromAltSvc("host", `h2=":443"`) + require.False(t, c.preferH3("host"), "Alt-Svc without h3 should drop support") +} + +// fakeRoundTripper is a stand-in for the TCP transport. +type fakeRoundTripper struct { + header http.Header + calls int +} + +func (f *fakeRoundTripper) RoundTrip(*http.Request) (*http.Response, error) { + f.calls++ + h := f.header + if h == nil { + h = http.Header{} + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: h, + Body: io.NopCloser(strings.NewReader("")), + }, nil +} + +func TestH3FallbackLearnsFromAltSvc(t *testing.T) { + h := newH3Fallback(nil, &tls.Config{}, time.Minute) + defer h.Close() + fake := &fakeRoundTripper{header: http.Header{"Alt-Svc": {`h3=":443"; ma=86400`}}} + h.tcp = fake + + require.False(t, h.caps.preferH3("example.com")) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://example.com/ipfs/bafkqaaa", nil) + require.NoError(t, err) + resp, err := h.RoundTrip(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 1, fake.calls, "first request goes over TCP") + require.True(t, h.caps.preferH3("example.com"), "Alt-Svc h3 should be learned") +} + +func TestH3FallbackFallsBackWhenQUICFails(t *testing.T) { + fake := &fakeRoundTripper{} + h := &h3Fallback{ + tcp: fake, + quic: &http3.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + // Fail fast: nothing listens on UDP below, so the handshake times out. + QUICConfig: &quic.Config{HandshakeIdleTimeout: 200 * time.Millisecond}, + }, + caps: newH3Capabilities(time.Hour), + } + defer h.Close() + + // Pretend a previous response taught us this host serves HTTP/3. + const authority = "127.0.0.1:1" // port 1: no QUIC listener + h.caps.learnFromAltSvc(authority, `h3=":1"`) + require.True(t, h.caps.preferH3(authority)) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+authority+"/ipfs/bafkqaaa", nil) + require.NoError(t, err) + resp, err := h.RoundTrip(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, 1, fake.calls, "request should fall back to TCP after QUIC fails") + require.False(t, h.caps.preferH3(authority), "failed host should enter back-off") +} + +func TestH3FallbackSkipsH3WhenNotReplayable(t *testing.T) { + fake := &fakeRoundTripper{} + h := &h3Fallback{ + tcp: fake, + quic: &http3.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + QUICConfig: &quic.Config{HandshakeIdleTimeout: 200 * time.Millisecond}, + }, + caps: newH3Capabilities(time.Hour), + } + defer h.Close() + + h.caps.learnFromAltSvc("example.com", `h3=":443"`) + require.True(t, h.caps.preferH3("example.com")) + + // A request with a body cannot be replayed over HTTP/2, so HTTP/3 must be + // skipped entirely (not attempted and failed, which would set a back-off). + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://example.com/x", strings.NewReader("x")) + require.NoError(t, err) + _, err = h.RoundTrip(req) + require.NoError(t, err) + require.Equal(t, 1, fake.calls) + require.True(t, h.caps.preferH3("example.com"), "h3 must be skipped, not attempted, for body requests") + + // Plaintext http cannot use QUIC (always TLS), so HTTP/3 must be skipped. + req2, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com/x", nil) + require.NoError(t, err) + _, err = h.RoundTrip(req2) + require.NoError(t, err) + require.Equal(t, 2, fake.calls) + require.True(t, h.caps.preferH3("example.com"), "h3 must be skipped for plaintext http") +} + +func TestH3PeerstorePersistence(t *testing.T) { + ps, err := pstoremem.NewPeerstore() + require.NoError(t, err) + defer ps.Close() + + p := test.RandPeerIDFatal(t) + u, err := url.Parse("https://example.com") + require.NoError(t, err) + urls := []network.ParsedURL{{URL: u}} + + src := newH3Fallback(nil, &tls.Config{}, time.Minute) + defer src.Close() + + // Nothing learned yet: persisted verdict is false, a fresh cache stays h2. + src.persistToPeerstore(ps, p, urls) + cold := newH3Fallback(nil, &tls.Config{}, time.Minute) + defer cold.Close() + cold.seedFromPeerstore(ps, p, urls) + require.False(t, cold.caps.preferH3("example.com")) + + // Learn HTTP/3, persist it, and a brand-new cache should pick it up. + src.caps.learnFromAltSvc("example.com", `h3=":443"`) + src.persistToPeerstore(ps, p, urls) + + warm := newH3Fallback(nil, &tls.Config{}, time.Minute) + defer warm.Close() + require.False(t, warm.caps.preferH3("example.com")) + warm.seedFromPeerstore(ps, p, urls) + require.True(t, warm.caps.preferH3("example.com"), "should restore h3 support from peerstore") +} diff --git a/bitswap/network/httpnet/httpnet.go b/bitswap/network/httpnet/httpnet.go index 7be48267d..0b1ace025 100644 --- a/bitswap/network/httpnet/httpnet.go +++ b/bitswap/network/httpnet/httpnet.go @@ -62,6 +62,11 @@ const http2proto = "HTTP/2.0" const peerstoreSupportsHeadKey = "http-retrieval-head-support" +// connKeepAlive is the keep-alive period shared by the TCP dialer and the +// HTTP/3 (QUIC) transport, so idle connections are probed on the same schedule +// and survive NAT/firewall timeouts regardless of the transport. +const connKeepAlive = 15 * time.Second + // Option allows to configure the Network. type Option func(net *Network) @@ -225,6 +230,9 @@ type Network struct { host host.Host client *http.Client + // h3 is the HTTP/3 round-tripper wrapping client.Transport. It is nil + // unless WithHTTP3() was set. Kept here so Stop() can close its UDP socket. + h3 *h3Fallback closeOnce sync.Once closing chan struct{} @@ -249,6 +257,7 @@ type Network struct { maxHTTPAddressesPerPeer int maxDontHaveErrors int httpWorkers int + enableHTTP3 bool allowlist map[string]struct{} denylist map[string]struct{} trackedEndpoints map[string]struct{} @@ -305,7 +314,7 @@ func New(host host.Host, opts ...Option) network.BitSwapNetwork { netdialer := &net.Dialer{ // Timeout for connects to complete. Timeout: htnet.dialTimeout, - KeepAlive: 15 * time.Second, + KeepAlive: connKeepAlive, // TODO for go1.23 // // KeepAlive config for sending probes for an active // // connection. @@ -358,6 +367,13 @@ func New(host host.Host, opts ...Option) network.BitSwapNetwork { return http.ErrUseLastResponse }, } + + // When HTTP/3 is enabled, wrap the TCP transport so requests prefer + // HTTP/3 for hosts that advertise it and fall back to HTTP/2 otherwise. + if htnet.enableHTTP3 { + htnet.h3 = newH3Fallback(t, tlsCfg, htnet.idleConnTimeout) + c.Transport = htnet.h3 + } htnet.client = c pinger := newPinger(htnet, pingCid) @@ -399,6 +415,11 @@ func (ht *Network) Stop() { ht.connEvtMgr.Stop() ht.closeOnce.Do(func() { ht.cooldownTracker.stopCleaner() + if ht.h3 != nil { + if err := ht.h3.Close(); err != nil { + log.Debugf("error closing HTTP/3 transport: %s", err) + } + } close(ht.closing) }) } @@ -531,6 +552,13 @@ func (ht *Network) Connect(ctx context.Context, pi peer.AddrInfo) error { urls = urls[0:ht.maxHTTPAddressesPerPeer] } + // If HTTP/3 is enabled, prime the per-host cache from what we previously + // remembered about this peer, so the probe below (and later requests) can + // prefer HTTP/3 right away instead of re-learning it via Alt-Svc. + if ht.h3 != nil { + ht.h3.seedFromPeerstore(ht.host.Peerstore(), p, urls) + } + // Try to talk to the peer by making HTTP requests to its urls and // recording which ones work. This allows re-using the connections // that we are about to open next time with the client. We call @@ -584,6 +612,12 @@ func (ht *Network) Connect(ctx context.Context, pi peer.AddrInfo) error { // Record whether HEAD test passed for all urls - ignoring error _ = ps.Put(pi.ID, peerstoreSupportsHeadKey, supportsHead) + // Remember whether this peer's endpoint supports HTTP/3, so the verdict + // survives reconnects (and restarts with a persistent peerstore). + if ht.h3 != nil { + ht.h3.persistToPeerstore(ps, p, urls) + } + ht.pinger.startPinging(p) ht.connEvtMgr.Connected(p) @@ -609,10 +643,11 @@ func (ht *Network) connectToURL(ctx context.Context, p peer.ID, u network.Parsed // For HTTP, the address can only be a LAN IP as otherwise it would have // been filtered out before. - // So IF it is HTTPS and not http2, we abort because we don't want - // requests to non-local hosts without http2. - if u.URL.Scheme == "https" && resp.Proto != http2proto { - err = fmt.Errorf("%s://%q is not using HTTP/2 (%s)", req.URL.Scheme, req.URL.Host, resp.Proto) + // So IF it is HTTPS, we require HTTP/2 or HTTP/3: we don't want requests + // to non-local hosts over HTTP/1.x. HTTP/3 only appears when WithHTTP3() + // is set and the server upgraded the connection. + if u.URL.Scheme == "https" && resp.Proto != http2proto && resp.Proto != http3proto { + err = fmt.Errorf("%s://%q is not using HTTP/2 or HTTP/3 (%s)", req.URL.Scheme, req.URL.Host, resp.Proto) log.Warn(err) return resp.StatusCode, err } diff --git a/bitswap/network/httpnet/metrics.go b/bitswap/network/httpnet/metrics.go index 592803c14..d9c547994 100644 --- a/bitswap/network/httpnet/metrics.go +++ b/bitswap/network/httpnet/metrics.go @@ -52,7 +52,7 @@ func wantlistsSeconds(ctx context.Context) imetrics.Histogram { } func status(ctx context.Context) imetrics.CounterVec { - return imetrics.NewCtx(ctx, "status", "Request status count").CounterVec([]string{"method", "status", "host"}) + return imetrics.NewCtx(ctx, "status", "Request status count").CounterVec([]string{"method", "status", "host", "version"}) } type metrics struct { @@ -92,13 +92,31 @@ func newMetrics(endpoints map[string]struct{}) *metrics { WantlistsItemsTotal: wantlistsItemsTotal(ctx), WantlistsSeconds: wantlistsSeconds(ctx), ResponseSizes: responseSizes(ctx), - // labels: method, status, host + // labels: method, status, host, version Status: status(ctx), RequestTime: requestTime(ctx), } } -func (m *metrics) updateStatusCounter(method string, statusCode int, host string) { +// httpVersionLabel maps an http.Response.Proto to a short, bounded metrics +// label value (the ALPN protocol ids), so HTTP/2 and HTTP/3 traffic can be +// compared. An empty proto (e.g. a cancel, which makes no request) yields "". +func httpVersionLabel(proto string) string { + switch proto { + case http2proto: // "HTTP/2.0" + return "h2" + case http3proto: // "HTTP/3.0" + return "h3" + case "": + return "" + case "HTTP/1.1": + return "http/1.1" + default: + return "other" + } +} + +func (m *metrics) updateStatusCounter(method string, statusCode int, host, version string) { m.RequestsTotal.Inc() // Set host == other if we are: // - not tracking all hosts @@ -121,5 +139,5 @@ func (m *metrics) updateStatusCounter(method string, statusCode int, host string statusStr = "other" } - m.Status.WithLabelValues(method, statusStr, host).Inc() + m.Status.WithLabelValues(method, statusStr, host, version).Inc() } diff --git a/bitswap/network/httpnet/msg_sender.go b/bitswap/network/httpnet/msg_sender.go index dfde94ab0..84bf29fe8 100644 --- a/bitswap/network/httpnet/msg_sender.go +++ b/bitswap/network/httpnet/msg_sender.go @@ -293,7 +293,7 @@ func (sender *httpMsgSender) tryURL(ctx context.Context, u *senderURL, entry bsm sender.ht.metrics.RequestsInFlight.Dec() host := u.URL.Hostname() // updateStatusCounter - sender.ht.metrics.updateStatusCounter(req.Method, statusCode, host) + sender.ht.metrics.updateStatusCounter(req.Method, statusCode, host, httpVersionLabel(resp.Proto)) switch statusCode { // Valid responses signaling unavailability of the @@ -479,7 +479,7 @@ WANTLIST_LOOP: for i, entry := range wantlist { if entry.Cancel { // shortcut cancel entries. sender.ht.requestTracker.cancelRequest(entry.Cid) - sender.ht.metrics.updateStatusCounter("CANCEL", 0, "") + sender.ht.metrics.updateStatusCounter("CANCEL", 0, "", "") // Do not observe request time for cancel requests as they cost us // nothing, so it is unfair to compare against bsnet requests-time. // sender.ht.metrics.RequestTime.Observe(float64(time.Since(reqStart)) diff --git a/go.mod b/go.mod index 6ce0e17f2..fb70abb45 100644 --- a/go.mod +++ b/go.mod @@ -54,6 +54,7 @@ require ( github.com/polydawn/refmt v0.90.0 github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_model v0.6.2 + github.com/quic-go/quic-go v0.59.0 github.com/slok/go-http-metrics v0.13.0 github.com/spaolacci/murmur3 v1.1.0 github.com/stretchr/testify v1.11.1 @@ -141,7 +142,6 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/ucarion/urlpath v0.0.0-20200424170820-7ccc79b76bbb // indirect