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:
@@ -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/`)
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
certs/
|
||||
__pycache__/
|
||||
out/
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# what the Hypercorn/aioquic server wrote to stderr during 07 (the loss run), exception lines only, counted
|
||||
Executable
+96
@@ -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"
|
||||
Executable
+22
@@ -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
|
||||
@@ -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})
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Executable
+7
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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("-"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user