Skip to content

HTTP/3 eager probing: happy eyeballs for the Alt-Svc upgrade - #45

Merged
passcod merged 5 commits into
mainfrom
claude/http3-happy-eyeballs-qovn11
Aug 10, 2026
Merged

HTTP/3 eager probing: happy eyeballs for the Alt-Svc upgrade#45
passcod merged 5 commits into
mainfrom
claude/http3-happy-eyeballs-qovn11

Conversation

@passcod

@passcod passcod commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Design doc + implementation. No foreground request ever waits on an unverified HTTP/3 path.

Problem

An Alt-Svc advertisement says the server listens on UDP; it cannot say there is UDP connectivity between us and it. The next foreground request after an advertisement found out inline: on a silently broken UDP path it stalled until the QUIC idle timeout (~30s) or upgradeAttemptTimeout (60s) before the TCP clone-fallback fired — recurring every upgradeFailedTtl, sacrificing a random request each cycle, forever.

What this does

Background probe (default on, upgradeProbe: false restores inline): advertisements only make an origin probe-worthy; a background HEAD / with Version::HTTP_3 verifies the path, and foreground traffic keeps to TCP until it has. The probe rides the raw reqwest::Client, below the middleware stack — load-bearing three ways: the HTTP cache can't fake a confirmation with a replayed h3 response, the Alt-Svc middleware can't recurse into probing, and the probe's QUIC connection lands in the shared h3 pool so the first upgraded request starts warm. Any HTTP/3 response confirms, whatever its status. Hints still seed confirmed directly (a hint is the user's own assertion; h3-only origins need the first request to speak h3). Probes are aborted on Agent::close(). The confirmed-then-breaks protections from #23 (clone fallback, attempt timeout, cancel strikes) are untouched, and wrongful demotions (#27) now recover via probe instead of a foreground stall.

Path-time preference (upgradeSlowFactor, default 2.5; upgradeSlowTtl, default 600s): a per-origin EWMA of time-to-response-headers per protocol family, demoting an origin whose QUIC path is sustainedly slower than its TCP path — factor 2.5 and an absolute 10ms floor, minimum 8 samples per side, h3 preferred at parity and when moderately slower. A slow origin is not broken: it re-enters through a probe after the TTL, so "has the path improved?" never costs a foreground request either. h1 vs h2 is never acted on.

Results

In the blackhole harness (test/http3-probe.test.js): slowest foreground request against a broken UDP path drops from 30–60s to 3ms; the first upgraded request after a healthy probe completes in 2ms on loopback (warm connection). Recovery after the failure cooldown re-enters through a probe with no foreground stall.

Tests

  • 43 Rust unit tests (9 new: probe state machine, single-flight, hint semantics, EWMA demotion thresholds and re-entry)
  • New test/http3-probe.test.js: blackholed-path latency budget, background upgrade, recovery cycle
  • http3-advertised-port.test.js opts out of probing where it pins inline clone-before-rewrite mechanics
  • Conformance h3-upgrade dimension polls for the upgrade instead of assuming a fixed request count
  • Full suite green: 1401 JS assertions, conformance matrix passes on available rows

The design doc lived at docs/superpowers/specs/2026-08-10-h3-eager-probe-design.md through this PR's history (removed by the final unplan commit, per convention). It records the concept, the quinn/reqwest ground truth (why probe padding can't test MTU — DPLPMTUD owns datagram sizes; the handshake already proves 1200 bytes both directions), and rejected alternatives (per-request racing, direct quinn probing, shadow requests); the unplan commit message records the deviations.

Relates to #23's fallout, #24, #27, #33. Follow-ups filed as #46#56.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Xext8SsXtqrZfU4j7HsnW

claude added 2 commits August 10, 2026 04:12
… upgrade)

The Alt-Svc upgrade currently sacrifices a real request to discover whether
HTTP/3 actually works: when the UDP path silently drops datagrams, the first
upgraded request stalls for 30-60s before falling back to TCP, and the cycle
repeats on every failed-TTL expiry.

This design keeps all foreground traffic on TCP until a background probe -- a
HEAD request over HTTP/3 on the shared raw reqwest client -- proves the QUIC
path. Sharing the client means the probe bypasses the cache and Alt-Svc
middleware while landing its connection in the same h3 pool, so confirmation
doubles as prewarming. Hints keep seeding confirmed directly, and the
confirmed-then-breaks protections from #23 are untouched.

Also sketches a follow-on: per-origin, per-protocol EWMA of time-to-headers,
so a working-but-much-slower h3 path can be demoted to TCP with a handicap
that still favours h3 at parity, re-evaluated through the same probe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Xext8SsXtqrZfU4j7HsnW
Corrects the earlier claim that a completed handshake only proves the
download direction at full size: RFC 9000 §14.1 pads client Initials to
1200 bytes too, so both directions are proven at the QUIC base MTU.

Evaluates padding the probe (body-on-HEAD or oversized headers) to
exercise the upload path further. Verified against quinn-proto 0.11.13
and reqwest 0.13.4 sources: application data cannot force datagram sizes
above the validated MTU -- quinn starts at 1200 and only raises it via
DPLPMTUD probe packets, with black-hole detection walking it back -- so
padding adds nothing for MTU. It would only test sustained-flow policing,
for which a ~4KB high-entropy junk header (readable-before-response,
431-still-confirms) beats a HEAD body (cut short by early response +
STOP_SENDING). Deferred as an upgradeProbePadding option if that failure
family materialises.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Xext8SsXtqrZfU4j7HsnW
An Alt-Svc advertisement says the server listens on UDP; it cannot say
there is UDP connectivity between us and it. Until now the next foreground
request after an advertisement found out inline, stalling 30-60s on a
silently broken path before falling back to TCP -- and again every failed
TTL, forever, one sacrificed request per cycle.

Advertisements now only make an origin *probe-worthy*: a background HEAD
over HTTP/3 verifies the path, and foreground requests keep to TCP until
it has. The probe rides the raw reqwest client, below the middleware
stack, which is load-bearing three ways: the HTTP cache cannot fake a
confirmation with a replayed h3 response, the Alt-Svc middleware cannot
recurse into probing, and the probe's QUIC connection lands in the shared
h3 pool so the first upgraded request starts warm. Any HTTP/3 response
confirms, whatever its status; hints still seed confirmed directly, since
a hint is the user's own assertion and h3-only origins need the first
request to speak h3. upgradeProbe: false restores the inline upgrade for
operators who cannot tolerate synthetic requests.

On top of the probe, per-origin path-time preference: an EWMA of
time-to-response-headers per protocol family, demoting an origin whose
QUIC path is sustainedly slower than its TCP path (factor 2.5 and an
absolute 10ms floor, 8 samples per side minimum). A slow origin is not
broken: it re-enters through a probe after upgradeSlowTtl, so asking
"has the path improved?" never costs a foreground request either.

In the blackhole harness, the slowest foreground request against a broken
UDP path drops from 30-60s to single-digit milliseconds, and the first
upgraded request after a healthy probe completes in ~2ms on loopback.

The advertised-port tests opt out of probing where they pin inline
mechanics; the conformance upgrade dimension now polls for the upgrade
instead of assuming it lands on a fixed request count.

Implements docs/superpowers/specs/2026-08-10-h3-eager-probe-design.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Xext8SsXtqrZfU4j7HsnW
@passcod passcod changed the title docs: design for eager HTTP/3 probing (happy eyeballs for the Alt-Svc upgrade) HTTP/3 eager probing: happy eyeballs for the Alt-Svc upgrade Aug 10, 2026
claude added 2 commits August 10, 2026 05:09
Everything in the design landed in this PR, with four deviations worth
recording since the plan goes away with this commit:

The path-time EWMA follow-on shipped alongside the probe rather than
after it, as upgradeSlowFactor/upgradeSlowTtl. The probe machinery is
what makes the demotion policy affordable, and implementing them
together avoided touching the routing decision twice.

The single-flight property is asserted at the cache layer (claim/finish
unit tests) rather than by counting relay datagrams as the plan
suggested -- handshake retransmissions make datagram counts an
unreliable proxy for attempt counts.

The close-during-probe case is handled (close() aborts probe tasks via
their AbortHandles) but has no dedicated JS test; the probing claim's
TTL covers the aborted probe's origin, and the behaviour is exercised
incidentally by every test that closes its agent while the harness is
still advertising.

The probing claim's lifetime when upgradeProbeTimeout is 0 (no deadline)
is 125s -- the QUIC idle timeout ceiling plus slack -- which the plan
left unspecified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Xext8SsXtqrZfU4j7HsnW
@passcod
passcod marked this pull request as ready for review August 10, 2026 05:16
@passcod
passcod merged commit cb2c15d into main Aug 10, 2026
28 checks passed
@passcod
passcod deleted the claude/http3-happy-eyeballs-qovn11 branch August 10, 2026 05:41
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