Add http3 module: HTTP/3 in the Java HTTP Client (JEP 517), opt-in, discovery modes, fallback timings, proxy downgrade and a packet-loss comparison

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh
This commit is contained in:
2026-09-30 20:27:46 +00:00
co-authored by Claude Sonnet 5.5
parent c8ff3acf09
commit fadfd05670
25 changed files with 617 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
"""The test application. respond() is shared by the Hypercorn ASGI wrapper (HTTP/1.1, HTTP/2) and the aioquic
HTTP/3 server. It reports which HTTP version the request arrived on, returns N bytes for /bytes/N, and can redirect."""
import os
ALT_SVC = os.environ.get("ALT_SVC_PORT") # set by serve.py in h3 mode: advertise h3 on that UDP port
def respond(path, version, host, port):
hdrs = []
if ALT_SVC:
hdrs.append((b"alt-svc", ('h3=":%s"; ma=3600' % ALT_SVC).encode()))
if path.startswith("/bytes/"):
body = b"x" * int(path.rsplit("/", 1)[1]); ctype = b"application/octet-stream"; status = 200
elif path == "/redirect":
hdrs.append((b"location", b"/hello")); return 302, hdrs, b""
else:
body = ("hello from %s:%s via HTTP/%s\n" % (host, port, version)).encode(); ctype = b"text/plain"; status = 200
hdrs += [(b"content-type", ctype), (b"content-length", str(len(body)).encode())]
return status, hdrs, body
async def app(scope, receive, send):
if scope["type"] == "lifespan":
while True:
m = await receive()
if m["type"] == "lifespan.startup":
await send({"type": "lifespan.startup.complete"})
elif m["type"] == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"}); return
if scope["type"] != "http":
return
while (await receive()).get("more_body"): # drain the request body
pass
status, headers, body = respond(scope["path"], scope["http_version"], scope["server"][0], scope["server"][1])
await send({"type": "http.response.start", "status": status, "headers": headers})
await send({"type": "http.response.body", "body": body})
+25
View File
@@ -0,0 +1,25 @@
"""A 25-line HTTP CONNECT proxy, just enough to show that the JDK will not send HTTP/3 through one."""
import asyncio, sys
async def pipe(r, w):
try:
while data := await r.read(65536):
w.write(data); await w.drain()
finally:
w.close()
async def handle(cr, cw):
line = (await cr.readline()).decode()
while (await cr.readline()) not in (b"\r\n", b""):
pass
method, target, _ = line.split()
print("proxy saw:", method, target, flush=True)
host, port = target.rsplit(":", 1)
ur, uw = await asyncio.open_connection(host, int(port))
cw.write(b"HTTP/1.1 200 Connection established\r\n\r\n"); await cw.drain()
await asyncio.gather(pipe(cr, uw), pipe(ur, cw))
async def main():
srv = await asyncio.start_server(handle, "127.0.0.1", int(sys.argv[1]))
async with srv: await srv.serve_forever()
asyncio.run(main())
+46
View File
@@ -0,0 +1,46 @@
"""Minimal HTTP/3 server on aioquic (the library Hypercorn uses for QUIC). It answers the same paths as app.py.
Hypercorn's own QUIC listener proved unreliable under packet loss (its UDPServer task dies on a KeyError and the
port then goes silent), so HTTP/3 is served from here and Hypercorn serves only TCP."""
import asyncio, ssl
from aioquic.asyncio import QuicConnectionProtocol, serve as quic_serve
from aioquic.h3.connection import H3_ALPN, H3Connection
from aioquic.h3.events import HeadersReceived, DataReceived
from aioquic.quic.configuration import QuicConfiguration
from aioquic.quic.events import ProtocolNegotiated
from app import respond # (path, http_version, host, port) -> (status, headers, body)
class H3Protocol(QuicConnectionProtocol):
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self._http = None
self._pending = {}
def quic_event_received(self, event):
if isinstance(event, ProtocolNegotiated) and event.alpn_protocol in H3_ALPN:
self._http = H3Connection(self._quic)
if self._http is None:
return
for ev in self._http.handle_event(event):
if isinstance(ev, HeadersReceived):
self._pending[ev.stream_id] = dict(ev.headers)
if ev.stream_ended: self._reply(ev.stream_id)
elif isinstance(ev, DataReceived) and ev.stream_ended:
self._reply(ev.stream_id)
def _reply(self, stream_id):
h = self._pending.pop(stream_id, None)
if h is None: return
status, headers, body = respond(h[b":path"].decode(), "3", "127.0.0.1", self._port)
self._http.send_headers(stream_id, [(b":status", str(status).encode())] + headers)
self._http.send_data(stream_id, body, end_stream=True)
self.transmit()
_port = 4433
async def start(port, certfile, keyfile):
H3Protocol._port = port
cfg = QuicConfiguration(is_client=False, alpn_protocols=H3_ALPN)
cfg.load_cert_chain(certfile, keyfile)
await quic_serve("127.0.0.1", port, configuration=cfg, create_protocol=H3Protocol)
await asyncio.Event().wait()
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Self-signed certificate for localhost / 127.0.0.1, valid 30 days. Only the test server uses it.
set -euo pipefail
cd "$(dirname "$0")/.."; mkdir -p certs
openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout certs/key.pem -out certs/cert.pem \
-subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2>/dev/null
echo "wrote certs/cert.pem and certs/key.pem"
+29
View File
@@ -0,0 +1,29 @@
"""serve.py MODE [PORT]
h3 HTTP/1.1 + HTTP/2 on TCP (Hypercorn) and HTTP/3 on UDP (aioquic), same port number, Alt-Svc advertised
h2 TCP only, ALPN h2 + http/1.1
h1 TCP only, ALPN http/1.1
"""
import asyncio, os, sys
mode = sys.argv[1]; port = sys.argv[2] if len(sys.argv) > 2 else "4433"
if mode == "h3": os.environ["ALT_SVC_PORT"] = port
from hypercorn.asyncio import serve
from hypercorn.config import Config
from app import app
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
c = Config()
c.certfile, c.keyfile = f"{root}/certs/cert.pem", f"{root}/certs/key.pem"
c.bind = [f"127.0.0.1:{port}"]
c.loglevel = "WARNING"
if mode == "h1":
c.alpn_protocols = ["http/1.1"]
elif mode not in ("h2", "h3"):
sys.exit("mode must be h3, h2 or h1")
async def main():
tasks = [serve(app, c)]
if mode == "h3":
import h3server
tasks.append(h3server.start(int(port), c.certfile, c.keyfile))
await asyncio.gather(*tasks)
asyncio.run(main())