HTTP/3 eager probing: happy eyeballs for the Alt-Svc upgrade - #45
Merged
Conversation
… 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
This was referenced Aug 10, 2026
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
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Xext8SsXtqrZfU4j7HsnW
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 everyupgradeFailedTtl, sacrificing a random request each cycle, forever.What this does
Background probe (default on,
upgradeProbe: falserestores inline): advertisements only make an origin probe-worthy; a backgroundHEAD /withVersion::HTTP_3verifies the path, and foreground traffic keeps to TCP until it has. The probe rides the rawreqwest::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 seedconfirmeddirectly (a hint is the user's own assertion; h3-only origins need the first request to speak h3). Probes are aborted onAgent::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
test/http3-probe.test.js: blackholed-path latency budget, background upgrade, recovery cyclehttp3-advertised-port.test.jsopts out of probing where it pins inline clone-before-rewrite mechanicsh3-upgradedimension polls for the upgrade instead of assuming a fixed request countThe design doc lived at
docs/superpowers/specs/2026-08-10-h3-eager-probe-design.mdthrough 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