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
+1
View File
@@ -68,6 +68,7 @@ These modules keep only sources, a `run.sh` and their transcripts (`output/`). T
| [`patterns/`](patterns) | Pattern Matching in Java: switch, Record Patterns and Primitive Patterns (JDK 21 to 27) | type and record patterns, guards and dominance errors, sealed exhaustiveness (with JDK 27's new `missing patterns` lines), `MatchException` from a throwing accessor and from separate compilation, unnamed patterns, primitive patterns with `--enable-preview` and the preview class-file trap |
| [`sealed/`](sealed) | Sealed Classes and Interfaces: Modelling Domains with Exhaustive switch | a `Payment` model, `permits` and the three subclass modifiers, the `default` trap, generic and recursive hierarchies, the visitor for comparison, run-time enforcement and modules |
| [`lazy-constants/`](lazy-constants) | Lazy Constants in JDK 27 (JEP 531): Replacing Double-Checked Locking | `LazyConstant` against the holder idiom and double-checked locking, the 26 to 27 failure-semantics change, `List`/`Map`/`Set.ofLazy`, the preview-flag traps and a JMH read-path benchmark |
| [`http3/`](http3) | HTTP/3 in the Java HTTP Client (JEP 517): Migrating from HttpClient 11 | Java 11 code unchanged on 25 and 26, the `HTTP_3` opt-in on client vs request, the three `H3_DISCOVERY` modes, fallback timings (including the 60 s `HTTP_3_URI_ONLY` failure), proxy downgrade, HTTP/1.1 vs 2 vs 3 under iptables packet loss, with a local Hypercorn + aioquic server |
## Captured output (`docs/output/`)
+3
View File
@@ -0,0 +1,3 @@
certs/
__pycache__/
out/
+42
View File
@@ -0,0 +1,42 @@
# http3 — HTTP/3 in the Java HTTP Client (JEP 517, final in Java 26)
Companion code for the ankurm.com article **HTTP/3 in the Java HTTP Client (JEP 517): Migrating from HttpClient 11**. All explanation lives in the
article; this folder holds the runnable sources, a local HTTP/3 test server, and the transcripts they produced.
## What was tested against
| Thing | Version | Note |
|---|---|---|
| JDK 26 | Temurin 26.0.2.1+1 | JEP 517 is delivered in 26 (GA 2026-03-17); this is the only JDK that can run HTTP/3 here |
| JDK 25 | Temurin 25.0.4.1+1 | the "before": `HttpClient.Version.HTTP_3` does not exist, plain Java 11 code behaves the same as on 26 |
| Test server | Hypercorn 0.18.0 (HTTP/1.1 + HTTP/2 on TCP) + aioquic 1.3.0 (HTTP/3 on UDP) | one Python process, same port number for both, `Alt-Svc` advertised. Hypercorn's own QUIC listener was dropped: it died on a `KeyError` under packet loss and the port went silent |
| Loss injection | `iptables -m statistic --mode random` on `lo` | **not** `tc netem` (unavailable in the sandbox: `Specified qdisc kind is unknown`): loss only, no added delay |
The JDK 26 lane in the other modules of this repo uses Amazon Corretto 26.0.2.1; this module used Temurin because that is what the sandbox had. The API is the same.
## Quickstart
```bash
pip install hypercorn==0.18.0 aioquic==1.3.0
JDK25=/path/to/jdk-25 JDK26=/path/to/jdk-26 ./run.sh # about 45 minutes: the `HTTP_3_URI_ONLY` failure demos wait a full minute each and the loss table is 600 trials; needs root for 05 and 07; ONLY=07 regenerates just the loss table
# or by hand:
./server/make-cert.sh && python3 server/serve.py h3 4433 & # h3 | h2 | h1
java src/OptIn.java https://localhost:4433/hello request
```
## Layout
| Path | What is in it |
|---|---|
| `src/Tls.java` | trusts only the self-signed test certificate |
| `src/Basics.java` | plain Java 11 `HttpClient` code, no version set |
| `src/OptIn.java` | the JEP 517 opt-in, client-wide or per request |
| `src/Discovery.java` | the three `H3_DISCOVERY` modes |
| `src/Fallback.java` | first-request latency and version for each way of asking, against servers that cannot do HTTP/3 |
| `src/ViaProxy.java` | HTTP/3 and a proxy |
| `src/Bench.java` | HTTP/1.1 vs HTTP/2 vs HTTP/3 download times under packet loss |
| `server/` | `app.py` (shared responses + ASGI wrapper), `serve.py` (h3 / h2 / h1 modes), `h3server.py` (aioquic HTTP/3), `connect-proxy.py`, `make-cert.sh` |
| `scripts/loss.sh` | adds and removes the iptables loss rules and the MTU change |
| `output/` | every transcript quoted in the article (`01`-`08`) |
Timings are machine dependent and the loss is random: treat `07-packet-loss.txt` as shape, not a benchmark of HTTP/3 implementations. The server is Python, the client is the JDK.
+11
View File
@@ -0,0 +1,11 @@
$ java src/Basics.java https://localhost:4433/hello (OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS))
request 1: HTTP_2 200 alt-svc=h3=":4433"; ma=3600
request 2: HTTP_2 200 alt-svc=h3=":4433"; ma=3600
request 3: HTTP_2 200 alt-svc=h3=":4433"; ma=3600
$ java src/Basics.java https://localhost:4433/hello (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1))
request 1: HTTP_2 200 alt-svc=h3=":4433"; ma=3600
request 2: HTTP_2 200 alt-svc=h3=":4433"; ma=3600
request 3: HTTP_2 200 alt-svc=h3=":4433"; ma=3600
# the alt-svc value above is the server advertising h3 on UDP 4433; nobody asked the client to use it.
+29
View File
@@ -0,0 +1,29 @@
$ java src/OptIn.java https://localhost:4433/hello client (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1))
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 2: HTTP_3
client-level HTTP_3, request 3: HTTP_3
client-level HTTP_3, request 4: HTTP_3
$ java src/OptIn.java https://localhost:4433/hello request (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1))
request-level HTTP_3, request 1: HTTP_3
request-level HTTP_3, request 2: HTTP_3
request-level HTTP_3, request 3: HTTP_3
request-level HTTP_3, request 4: HTTP_3
$ java src/OptIn.java https://localhost:4433/hello client (OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS))
src/OptIn.java:12: error: cannot find symbol
if (where.equals("client")) builder.version(HttpClient.Version.HTTP_3);
^
symbol: variable HTTP_3
location: class Version
src/OptIn.java:15: error: cannot find symbol
$ first request of a fresh client-level HTTP_3 client, 8 fresh JVMs (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1))
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
client-level HTTP_3, request 1: HTTP_2
+4
View File
@@ -0,0 +1,4 @@
$ java src/Discovery.java https://localhost:4433/hello (server: h1.1 + h2 on TCP, h3 on UDP, Alt-Svc advertised)
ANY -> HTTP_2 HTTP_3 HTTP_3 HTTP_3
ALT_SVC -> HTTP_2 HTTP_3 HTTP_3 HTTP_3
HTTP_3_URI_ONLY -> HTTP_3 HTTP_3 HTTP_3 HTTP_3
+19
View File
@@ -0,0 +1,19 @@
$ java src/Fallback.java https://localhost:4433/hello 120 (server: h2 + h1.1 on TCP only; nothing listens on UDP 4433)
request HTTP_3 (ANY) -> HTTP_2 after 3340 ms
client HTTP_3 (ANY) -> HTTP_2 after 31 ms
request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60016 ms
$ java -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 src/Fallback.java https://localhost:4433/hello 120 (same server)
request HTTP_3 (ANY) -> HTTP_2 after 501 ms
client HTTP_3 (ANY) -> HTTP_2 after 45 ms
request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60016 ms
$ java -Djdk.httpclient.quic.maxInitialTimeout=3 src/Fallback.java https://localhost:4433/hello 120 (same server)
request HTTP_3 (ANY) -> HTTP_2 after 3338 ms
client HTTP_3 (ANY) -> HTTP_2 after 39 ms
request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 3 seconds after 6011 ms
$ java src/Discovery.java https://localhost:4433/hello (server: HTTP/1.1 only, TCP)
ANY -> HTTP_1_1 HTTP_1_1 HTTP_1_1 HTTP_1_1
ALT_SVC -> HTTP_1_1 HTTP_1_1 HTTP_1_1 HTTP_1_1
HTTP_3_URI_ONLY -> ConnectException(No response from peer for 30 seconds) ConnectException(No response from peer for 30 seconds) ConnectException(No response from peer for 30 seconds) ConnectException(No response from peer for 30 seconds)
+5
View File
@@ -0,0 +1,5 @@
$ iptables: DROP 100% of UDP to/from port 4433 (TCP untouched); server is the h3 server
$ java src/Fallback.java https://localhost:4433/hello 120
request HTTP_3 (ANY) -> HTTP_2 after 3397 ms
client HTTP_3 (ANY) -> HTTP_2 after 46 ms
request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60014 ms
+6
View File
@@ -0,0 +1,6 @@
$ java src/ViaProxy.java https://localhost:4433/hello 4480 (client.proxy(...) points at a CONNECT proxy on 4480)
ANY via proxy -> HTTP_2
HTTP_3_URI_ONLY via proxy -> java.net.http.UnsupportedProtocolVersionException: can't use HTTP/3 with proxied or unsecured connection
$ proxy log
proxy saw: CONNECT localhost:4433
+38
View File
@@ -0,0 +1,38 @@
# loss = per-packet drop probability, applied in EACH direction, TCP and UDP port 4433, by iptables -m statistic on lo (MTU 1500).
# no added latency (loopback RTT is microseconds). 20 trials per row after one discarded warm-up; OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)
# one = fresh client, 1 download of 1 MiB. many = fresh client, 10 parallel downloads of 100 KiB.
h1 one loss=0 ok=20 failed=0 median=59 p90=68 max=84 (ms)
h2 one loss=0 ok=20 failed=0 median=61 p90=90 max=97 (ms)
h3 one loss=0 ok=20 failed=0 median=129 p90=197 max=246 (ms)
h1 many loss=0 ok=20 failed=0 median=97 p90=114 max=143 (ms)
h2 many loss=0 ok=20 failed=0 median=118 p90=152 max=159 (ms)
h3 many loss=0 ok=20 failed=0 median=135 p90=167 max=214 (ms)
h1 one loss=1 ok=20 failed=0 median=61 p90=74 max=1278 (ms)
h2 one loss=1 ok=20 failed=0 median=57 p90=66 max=79 (ms)
h3 one loss=1 ok=20 failed=0 median=202 p90=273 max=1207 (ms)
h1 many loss=1 ok=20 failed=0 median=111 p90=1044 max=1054 (ms)
h2 many loss=1 ok=20 failed=0 median=160 p90=1039 max=1057 (ms)
h3 many loss=1 ok=20 failed=0 median=176 p90=232 max=454 (ms)
h1 one loss=2 ok=20 failed=0 median=87 p90=281 max=2082 (ms)
h2 one loss=2 ok=20 failed=0 median=57 p90=294 max=1082 (ms)
h3 one loss=2 ok=20 failed=0 median=216 p90=316 max=390 (ms)
h1 many loss=2 ok=20 failed=0 median=249 p90=1058 max=2818 (ms)
h2 many loss=2 ok=20 failed=0 median=282 p90=1056 max=1073 (ms)
h3 many loss=2 ok=20 failed=0 median=238 p90=296 max=305 (ms)
h1 one loss=5 ok=20 failed=0 median=100 p90=1070 max=1689 (ms)
h2 one loss=5 ok=20 failed=0 median=248 p90=1084 max=3057 (ms)
h3 one loss=5 ok=20 failed=0 median=302 p90=500 max=1508 (ms)
h1 many loss=5 ok=20 failed=0 median=1055 p90=2833 max=3496 (ms)
h2 many loss=5 ok=20 failed=0 median=1047 p90=1257 max=1305 (ms)
h3 many loss=5 ok=20 failed=0 median=322 p90=577 max=1330 (ms)
h1 one loss=10 ok=20 failed=0 median=322 p90=1578 max=3475 (ms)
h2 one loss=10 ok=20 failed=0 median=678 p90=2177 max=2742 (ms)
h3 one loss=10 ok=20 failed=0 median=968 p90=1169 max=1292 (ms)
h1 many loss=10 ok=20 failed=0 median=1509 p90=3370 max=6458 (ms)
h2 many loss=10 ok=20 failed=0 median=1478 p90=3370 max=13812 (ms)
h3 many loss=10 ok=20 failed=0 median=885 p90=1088 max=1250 (ms)
+1
View File
@@ -0,0 +1 @@
# what the Hypercorn/aioquic server wrote to stderr during 07 (the loss run), exception lines only, counted
Executable
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# HTTP/3 in the JDK HttpClient (JEP 517, final in JDK 26). Regenerates every file in output/.
# JDK25=/path JDK26=/path [TRIALS=20] ./run.sh
# Needs: python3 with `pip install "hypercorn[h3]"`, openssl, and (for 05/06) root with iptables + ip.
# The loss tests change the loopback MTU and add iptables rules; both are undone on exit.
set -uo pipefail
unset JAVA_TOOL_OPTIONS
HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE"
JDK25="${JDK25:-/opt/jdks/jdk-25.0.4.1+1}"; JDK26="${JDK26:-/opt/jdks/jdk26}"; TRIALS="${TRIALS:-20}"
OUT="$HERE/output"; mkdir -p "$OUT"
URL=https://localhost:4433/hello
[ -f certs/cert.pem ] || ./server/make-cert.sh
SPID=""; PPID_=""
start() { python3 server/serve.py "$1" 4433 >/dev/null 2>&1 & SPID=$!; sleep 2; }
stop() { [ -n "$SPID" ] && kill -9 "$SPID" 2>/dev/null; wait "$SPID" 2>/dev/null; SPID=""; }
cleanup() { stop; [ -n "$PPID_" ] && kill -9 "$PPID_" 2>/dev/null; scripts/loss.sh off 2>/dev/null; }
trap cleanup EXIT
v() { "$1/bin/java" -version 2>&1 | sed -n 2p; }
J25="$JDK25/bin/java"; J26="$JDK26/bin/java"
ONLY="${ONLY:-}" # ONLY=07 regenerates just the loss table
if [ -z "$ONLY" ]; then
# 01 - plain Java 11 code: same source, JDK 25 and 26, server offers HTTP/3. Nothing changes.
start h3
{
for j in "$JDK25" "$JDK26"; do echo "\$ java src/Basics.java $URL ($(v $j))"; "$j/bin/java" src/Basics.java $URL 2>&1; echo; done
echo "# the alt-svc value above is the server advertising h3 on UDP 4433; nobody asked the client to use it."
} > "$OUT/01-java11-code-unchanged.txt"
# 02 - the opt-in, client level and request level; and the JDK 25 failure
{
echo "\$ java src/OptIn.java $URL client ($(v $JDK26))"; "$J26" src/OptIn.java $URL client 2>&1
echo; echo "\$ java src/OptIn.java $URL request ($(v $JDK26))"; "$J26" src/OptIn.java $URL request 2>&1
echo; echo "\$ java src/OptIn.java $URL client ($(v $JDK25))"; "$J25" src/OptIn.java $URL client 2>&1 | head -6
echo; echo "\$ first request of a fresh client-level HTTP_3 client, 8 fresh JVMs ($(v $JDK26))"
for i in 1 2 3 4 5 6 7 8; do "$J26" src/OptIn.java $URL client 2>&1 | sed -n 1p; done
} > "$OUT/02-opt-in.txt"
# 03 - the three discovery modes against a server that speaks HTTP/3 (h3 server still running)
{ echo "\$ java src/Discovery.java $URL (server: h1.1 + h2 on TCP, h3 on UDP, Alt-Svc advertised)"; "$J26" src/Discovery.java $URL 2>&1; } > "$OUT/03-discovery-modes.txt"
stop
# 04 - fallback: servers that do not speak HTTP/3 at all
{
start h2
echo "\$ java src/Fallback.java $URL 120 (server: h2 + h1.1 on TCP only; nothing listens on UDP 4433)"
"$J26" src/Fallback.java $URL 120 2>&1
echo; echo "\$ java -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 src/Fallback.java $URL 120 (same server)"
"$J26" -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 src/Fallback.java $URL 120 2>&1
echo; echo "\$ java -Djdk.httpclient.quic.maxInitialTimeout=3 src/Fallback.java $URL 120 (same server)"
"$J26" -Djdk.httpclient.quic.maxInitialTimeout=3 src/Fallback.java $URL 120 2>&1
stop; start h1
echo; echo "\$ java src/Discovery.java $URL (server: HTTP/1.1 only, TCP)"
"$J26" src/Discovery.java $URL 2>&1
stop
} > "$OUT/04-fallback.txt"
# 05 - a firewall that drops all UDP to 4433 (common in offices): the server is h3-capable but unreachable over QUIC
start h3; scripts/loss.sh 100 udp
{
echo "\$ iptables: DROP 100% of UDP to/from port 4433 (TCP untouched); server is the h3 server"
echo "\$ java src/Fallback.java $URL 120"
"$J26" src/Fallback.java $URL 120 2>&1
} > "$OUT/05-udp-blocked.txt"
scripts/loss.sh off; stop
# 06 - a proxy: HTTP/3 is not sent through it
start h3; python3 server/connect-proxy.py 4480 > "$OUT/.proxy.log" 2>&1 & PPID_=$!; sleep 1
{
echo "\$ java src/ViaProxy.java $URL 4480 (client.proxy(...) points at a CONNECT proxy on 4480)"
"$J26" src/ViaProxy.java $URL 4480 2>&1
echo; echo "\$ proxy log"; cat "$OUT/.proxy.log"
} > "$OUT/06-proxy.txt"
kill $PPID_ 2>/dev/null; PPID_=""; rm -f "$OUT/.proxy.log"; stop
fi
# 07 - HTTP/1.1 vs HTTP/2 vs HTTP/3 under random packet loss (same h3-capable server process for all three)
python3 server/serve.py h3 4433 2> "$OUT/.srv.err" >/dev/null & SPID=$!; sleep 2
{
echo "# loss = per-packet drop probability, applied in EACH direction, TCP and UDP port 4433, by iptables -m statistic on lo (MTU 1500)."
echo "# no added latency (loopback RTT is microseconds). $TRIALS trials per row after one discarded warm-up; $(v $JDK26)"
echo "# one = fresh client, 1 download of 1 MiB. many = fresh client, 10 parallel downloads of 100 KiB."
for L in 0 1 2 5 10; do
scripts/loss.sh $L
for w in one many; do for p in h1 h2 h3; do
timeout 600 "$J26" src/Bench.java $p $w $TRIALS $L 2>&1
done; done
echo
done
} > "$OUT/07-packet-loss.txt"
scripts/loss.sh off; stop
{
echo "# what the Hypercorn/aioquic server wrote to stderr during 07 (the loss run), exception lines only, counted"
grep -E "(Error|Exception)[:(]" "$OUT/.srv.err" | sed "s/^[ |+-]*//" | sort | uniq -c | sort -rn
} > "$OUT/08-server-errors.txt"; rm -f "$OUT/.srv.err"
echo "wrote $OUT"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# loss.sh P drop P percent (may be fractional, e.g. 2.5) of packets in each direction on lo, port 4433, TCP and UDP
# loss.sh P udp the same, UDP only (100 udp = a firewall that blocks QUIC)
# loss.sh off remove the rules and restore the loopback MTU
# Loopback MTU is 65536, so TCP would send 64 KiB segments while QUIC sends ~1.2 KB datagrams; a per-packet drop rate
# would then be wildly unfair. While loss is on we set the loopback MTU to 1500 so both protocols use Ethernet-sized packets.
# Kernel-level random drop (iptables -m statistic). This is NOT tc netem (netem was unavailable in the sandbox:
# "Specified qdisc kind is unknown"), so it adds loss but no delay. Needs root / CAP_NET_ADMIN.
set -euo pipefail
CH=H3LOSS
off() { ip link set dev lo mtu 65536; iptables -D INPUT -i lo -j $CH 2>/dev/null || true; iptables -F $CH 2>/dev/null || true; iptables -X $CH 2>/dev/null || true; }
off
[ "${1:?P or off}" = off ] && exit 0
P=$(python3 -c "print(float('$1')/100)")
ip link set dev lo mtu 1500
iptables -N $CH
for proto in ${2:-tcp udp}; do
for side in --dport --sport; do
iptables -A $CH -p $proto $side 4433 -m statistic --mode random --probability "$P" -j DROP
done
done
iptables -A INPUT -i lo -j $CH
+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())
+16
View File
@@ -0,0 +1,16 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
/** Plain Java 11 HttpClient code. No version is set. The same source file runs on JDK 11 to 27. */
public class Basics {
public static void main(String[] args) throws Exception {
var client = Tls.builder().build(); // the only non-11 line is the self-signed trust
var request = HttpRequest.newBuilder(URI.create(args[0])).GET().build();
for (int i = 1; i <= 3; i++) {
HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("request " + i + ": " + r.version() + " " + r.statusCode() + " alt-svc=" + r.headers().firstValue("alt-svc").orElse("-"));
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpOption;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.CompletableFuture;
/**
* Bench PROTO WORKLOAD TRIALS LOSS_LABEL
* PROTO h1 | h2 | h3 (h3 uses HTTP_3_URI_ONLY so a silent fallback cannot flatter the result)
* WORKLOAD one = fresh client, one 1 MiB download (handshake + one stream)
* many = fresh client, 10 parallel 100 KiB downloads (handshake + ten streams)
* Every response is checked: same HTTP version as asked for, exact body length. A trial that throws or
* exceeds 15 s is counted as failed.
*/
public class Bench {
public static void main(String[] a) throws Exception {
String proto = a[0], workload = a[1], loss = a[3];
int trials = Integer.parseInt(a[2]);
var version = switch (proto) { case "h1" -> HttpClient.Version.HTTP_1_1; case "h2" -> HttpClient.Version.HTTP_2; default -> HttpClient.Version.HTTP_3; };
int n = workload.equals("one") ? 1 : 10;
int size = workload.equals("one") ? 1_048_576 : 102_400;
List<Long> times = new ArrayList<>();
int failed = 0;
String firstFailure = null;
for (int t = -1; t < trials; t++) { // trial -1 is a warm-up and is discarded
long t0 = System.nanoTime();
try (var client = Tls.builder().version(version).build()) {
var rb = HttpRequest.newBuilder(URI.create("https://localhost:4433/bytes/" + size)).timeout(Duration.ofSeconds(15));
if (version == HttpClient.Version.HTTP_3) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY);
var req = rb.build();
List<CompletableFuture<HttpResponse<byte[]>>> fs = new ArrayList<>();
for (int i = 0; i < n; i++) fs.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofByteArray()));
for (var f : fs) {
var r = f.get();
if (r.version() != version || r.body().length != size) throw new IllegalStateException(r.version() + " " + r.body().length);
}
if (t >= 0) times.add((System.nanoTime() - t0) / 1_000_000);
} catch (Exception e) {
if (t >= 0) { failed++; if (firstFailure == null) firstFailure = e.toString(); }
}
}
Collections.sort(times);
String stats = times.isEmpty() ? "n/a" : "median=%d p90=%d max=%d".formatted(times.get(times.size() / 2), times.get((int) Math.min(times.size() - 1, Math.ceil(times.size() * 0.9) - 1)), times.get(times.size() - 1));
System.out.printf("%s %-4s loss=%-3s ok=%d failed=%d %s (ms)%s%n", proto, workload, loss, times.size(), failed, stats, firstFailure == null ? "" : " first failure: " + firstFailure);
}
}
+28
View File
@@ -0,0 +1,28 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpOption.Http3DiscoveryMode;
import java.net.http.HttpOption;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
/** One fresh client, the three H3_DISCOVERY modes, four requests each. */
public class Discovery {
public static void main(String[] args) throws Exception {
URI uri = URI.create(args[0]);
for (Http3DiscoveryMode mode : Http3DiscoveryMode.values()) {
var client = Tls.builder().version(HttpClient.Version.HTTP_3).build();
var req = HttpRequest.newBuilder(uri).setOption(HttpOption.H3_DISCOVERY, mode).build();
StringBuilder sb = new StringBuilder();
for (int i = 1; i <= 4; i++) {
try {
var r = client.send(req, HttpResponse.BodyHandlers.ofString());
sb.append(' ').append(r.version());
} catch (Exception e) {
sb.append(' ').append(e.getClass().getSimpleName()).append('(').append(e.getMessage()).append(')');
}
}
System.out.printf("%-16s ->%s%n", mode, sb);
client.close();
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpOption;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** How long does the first request take, and which version answers, for each way of asking for HTTP/3? */
public class Fallback {
public static void main(String[] args) throws Exception {
URI uri = URI.create(args[0]);
long limitSeconds = Long.parseLong(args[1]);
for (String mode : new String[] {"request HTTP_3 (ANY)", "client HTTP_3 (ANY)", "request HTTP_3_URI_ONLY"}) {
var cb = Tls.builder();
if (mode.startsWith("client")) cb.version(HttpClient.Version.HTTP_3);
var rb = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(limitSeconds));
if (!mode.startsWith("client")) rb.version(HttpClient.Version.HTTP_3);
if (mode.endsWith("URI_ONLY")) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY);
long t0 = System.nanoTime();
String result;
try (var client = cb.build()) {
result = client.send(rb.build(), HttpResponse.BodyHandlers.ofString()).version().toString();
} catch (Exception e) {
result = e.getClass().getName() + ": " + e.getMessage();
}
System.out.printf("%-26s -> %-70s after %5d ms%n", mode, result, (System.nanoTime() - t0) / 1_000_000);
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
/** The JEP 517 opt-in, client-wide or per request. Prints the version every response really used. */
public class OptIn {
public static void main(String[] args) throws Exception {
URI uri = URI.create(args[0]);
String where = args.length > 1 ? args[1] : "client"; // "client" or "request"
var builder = Tls.builder();
if (where.equals("client")) builder.version(HttpClient.Version.HTTP_3);
var client = builder.build();
var req = HttpRequest.newBuilder(uri);
if (where.equals("request")) req.version(HttpClient.Version.HTTP_3);
for (int i = 1; i <= 4; i++) {
var r = client.send(req.build(), HttpResponse.BodyHandlers.ofString());
System.out.println(where + "-level HTTP_3, request " + i + ": " + r.version());
}
client.close();
}
}
+26
View File
@@ -0,0 +1,26 @@
import java.io.FileInputStream;
import java.net.http.HttpClient;
import java.security.KeyStore;
import java.security.cert.CertificateFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManagerFactory;
/** Trusts only the self-signed test certificate in certs/cert.pem (made by server/make-cert.sh). */
final class Tls {
static SSLContext context() throws Exception {
KeyStore ks = KeyStore.getInstance("PKCS12");
ks.load(null);
try (var in = new FileInputStream("certs/cert.pem")) {
ks.setCertificateEntry("test", CertificateFactory.getInstance("X.509").generateCertificate(in));
}
TrustManagerFactory tmf = TrustManagerFactory.getInstance("PKIX");
tmf.init(ks);
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, tmf.getTrustManagers(), null);
return ctx;
}
static HttpClient.Builder builder() throws Exception {
return HttpClient.newBuilder().sslContext(context());
}
}
+29
View File
@@ -0,0 +1,29 @@
import java.net.InetSocketAddress;
import java.net.ProxySelector;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpOption;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/** The Javadoc says HTTP/3 through a proxy is not supported: ANY silently downgrades, HTTP_3_URI_ONLY fails. */
public class ViaProxy {
public static void main(String[] args) throws Exception {
URI uri = URI.create(args[0]);
int proxyPort = Integer.parseInt(args[1]);
for (boolean uriOnly : new boolean[] {false, true}) {
var client = Tls.builder().version(HttpClient.Version.HTTP_3)
.proxy(ProxySelector.of(new InetSocketAddress("127.0.0.1", proxyPort))).build();
var rb = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(10));
if (uriOnly) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY);
try {
var r = client.send(rb.build(), HttpResponse.BodyHandlers.ofString());
System.out.println((uriOnly ? "HTTP_3_URI_ONLY" : "ANY ") + " via proxy -> " + r.version());
} catch (Exception e) {
System.out.println((uriOnly ? "HTTP_3_URI_ONLY" : "ANY ") + " via proxy -> " + e.getClass().getName() + ": " + e.getMessage());
}
client.close();
}
}
}