Skip to main content

Auto-Negotiation

By default you don't pick a protocol. The lib does. It races H3 and H2 in parallel, takes whichever connects first, and falls back to H1 only if H2 actually fails ALPN. Call it the "I don't care, just give me Chrome-shaped bytes on the right protocol" mode.

What doAuto actually does

The dispatcher in transport/transport.go is one function called doAuto. Steps:

  1. Look up the host in the protocolSupport cache. If we already learned this host's best protocol from a prior request, skip the race and just dial that.
  2. Otherwise fire two goroutines: one dials H3 over UDP via QUIC, the other dials H2 over TCP+TLS.
  3. Take the first success. Cancel the loser.
  4. If H2's TLS handshake came back with http/1.1 in ALPN (ALPNMismatchError), reuse that same TLS connection for an H1 request. No second handshake.
  5. If both attempts time out (default budget around 6 seconds), the lib tries H2 directly as a last resort, then H1.
  6. Cache the winning protocol in protocolSupport[host] so the next request to the same host skips the race.

The race lives in raceH3H2. It exists to dodge the 5-second wall you'd hit if you tried H3 first and the network silently swallowed UDP/443. With the race, H2 fills in the moment TCP comes back, usually under 200ms.

How H3 gets discovered

Two ways:

  • Alt-Svc. The first H2 response from a host carries alt-svc: h3=":443"; ma=86400. The lib parses it, the protocolSupport cache learns "this host speaks H3", and the next request can race H3 against H2. H3 usually wins because the QUIC handshake finishes in fewer round trips.
  • DNS HTTPS RR. RFC 9460 HTTPS records advertise ALPN values directly in DNS. If the resolver returns one with h3 in it, the lib can skip the H2 detour entirely. Whether this fires depends on your DNS config in dns/.

If neither hint mentions H3, the race still includes H3 on the first try, but H3's handshake is unlikely to land first.

When H1 actually shows up

H1's the boring fallback. You land here when:

  • The TLS server hello returns http/1.1 in ALPN. The ALPNMismatchError path reuses the connection.
  • Both H3 and H2 attempts fail outright and the lib has to try H1 on a fresh TCP connection.
  • You forced it with WithForceHTTP1() or RefreshWithProtocol("h1").

For normal browsing-shaped traffic against modern hosts, H1 should be rare.

Forcing one protocol

Three options at session construction:

  • WithForceHTTP1(): lock to H1. Skips H2/H3 entirely.
  • WithForceHTTP2(): lock to H2. Skips H3 and won't fall back to H1 unless ALPN drags it there.
  • WithForceHTTP3(): lock to H3. Hard fails if the host doesn't speak H3.

Plus one for the common middle case:

  • WithDisableHTTP3(): keep auto-negotiation but never try H3. Basically the "old-school client" knob.

For mid-session changes:

  • RefreshWithProtocol("h1" | "h2" | "h3") drops the connection pool and forces the named protocol from the next request.
  • WithSwitchProtocol("h2") at construction time queues a protocol switch on the next Refresh(). Handy for the warmup-on-H3, serve-on-H2 pattern when you want to share a TLS ticket across protocols.

Why force one

A handful of real reasons:

  • Tests. You want predictable behavior. Auto-negotiation can land on H2 or H3 depending on what the target's edge feels like advertising today.
  • Broken H3 at the target. Some hosts advertise h3 in Alt-Svc but their UDP port is firewalled, or the QUIC stack's busted. Auto-negotiation handles this by losing the race, but if you're slamming the same host millions of times you might as well skip the H3 attempt entirely with WithDisableHTTP3().
  • Policy. Your network only allows TCP/443. Force H2.
  • Fingerprint surface. You specifically want to test the H3 fingerprint your preset emits. Force H3 against a known H3-capable target.

Code: default vs forced

package main

import (
"context"
"fmt"
"time"

"github.com/sardanioss/httpcloak"
)

func hit(label string, opts ...httpcloak.SessionOption) {
sess := httpcloak.NewSession("chrome-latest", opts...)
defer sess.Close()

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

resp, err := sess.Get(ctx, "https://tls.peet.ws/api/all")
if err != nil {
fmt.Printf("[%s] err: %v\n", label, err)
return
}
defer resp.Close()
fmt.Printf("[%s] resp.Protocol=%s status=%d\n", label, resp.Protocol, resp.StatusCode)
}

func main() {
hit("default") // h2 against tls.peet
hit("force-h2", httpcloak.WithForceHTTP2()) // h2
hit("disable-h3", httpcloak.WithDisableHTTP3())
}

Expected output, hitting tls.peet.ws:

[default] resp.Protocol=h2 status=200
[force-h2] resp.Protocol=h2 status=200
[disable-h3] resp.Protocol=h2 status=200

All three land on H2 because tls.peet.ws's UDP/443 port is closed in practice, so the lib never gets an H3 path to win the race.

Per-host learning

Once a request to example.com comes back on H3, the lib caches that fact. The next request to example.com skips the race and dials H3 directly. The cache is keyed by hostname (no port, no path) and lives in protocolSupport. If the host stops responding on H3 later, you'll need to recreate the session or call RefreshWithProtocol("h2") to evict the cached choice.

There's a planned BrokenAltSvc circuit breaker that suppresses H3 attempts after repeated failures to a specific host without forcing a restart. Tracked in our internal docs, not landed yet.

Auto vs forced for production

For production traffic where you don't care which protocol you land on, leave it auto. The lib handles Alt-Svc, the H3 race, and ALPN fallback for you. For automation aimed at specific bot products, force the protocol your preset is shaped for. Most chrome-148 presets are tuned for H2 and H3 side by side, but if you're matching against a capture that was specifically H3, force H3 so you don't accidentally diff an H2 fingerprint against an H3 capture.

See also

  • HTTP/1.1 for what H1 negotiates and when it's the right call.
  • HTTP/2 for the SETTINGS, WINDOW_UPDATE, and Akamai signals on H2.
  • HTTP/3 (QUIC) for the QUIC INITIAL packet and PRIORITY_UPDATE.
  • Akamai shorthand for tweaking H2 fingerprint values without rebuilding a preset.