Learning objective. Turn a failed handshake into a diagnosis: recognize how failures surface in a trace, decode the error code, and map the common symptoms to their causes using real captured output.
Failures speak through CONNECTION_CLOSE #
A handshake that fails does not hang silently forever: one side gives up and sends a
CONNECTION_CLOSE frame carrying an error code (§5.4). Reading that code
is most of the diagnosis. Two families cover nearly all handshake failures:
- Transport error codes (
0x00–0x10,constants.md§7). The QUIC layer itself objected: a malformed frame (FRAME_ENCODING_ERROR), a bad transport parameter (TRANSPORT_PARAMETER_ERROR), a protocol violation. - CRYPTO_ERROR (
0x0100–0x01ff). TLS objected. The low byte is the TLS AlertDescription: a TLS alert is turned into a QUIC error by adding it to0x0100([RFC 9001 §4.8]). So0x0100 + alerttells you exactly which TLS check failed.
Reading real failures #
The §2.1 capture harness makes failures reproducible. The lab in
labs/02-5-handshake-failures/ points a client at a server it cannot complete a handshake
with (once with a mismatched ALPN, once verifying the server's self-signed certificate)
and reads the CONNECTION_CLOSE error out of the client's qlog. This is its genuine
output:
server: ALPN=['h3'], self-signed cert
ALPN mismatch : error 0x128 (CRYPTO_ERROR · TLS alert 40 = handshake_failure)
reason: "No common ALPN protocols"
Cert not trusted: error 0x12a (CRYPTO_ERROR · TLS alert 42 = bad_certificate)
reason: "hostname '127.0.0.1' doesn't match DNSPattern(...)"
Both codes are in the CRYPTO_ERROR range, so subtract 0x0100: 0x128 → 40 and 0x12a → 42, the TLS alerts handshake_failure and bad_certificate. The cert failure is
textbook. The client rejected the server's certificate, so the client sent the close.
The ALPN case is a useful subtlety: this stack rejected the missing common protocol at the
TLS layer with handshake_failure (40), whereas an HTTP/3 endpoint that negotiates ALPN
at its own layer would use no_application_protocol (0x0178, [RFC 9114 §8.1]). The
lesson is to decode the alert number rather than assume it: implementations differ on
which alert a given failure earns.
A field guide to handshake symptoms #
| Symptom in the trace | Likely cause |
|---|---|
CRYPTO_ERROR 0x012a/0x0130 (bad_certificate / certificate_unknown) |
server cert not trusted, wrong name, or expired |
CRYPTO_ERROR 0x0128 or 0x0178 (handshake_failure / no_application_protocol) |
ALPN mismatch — client and server share no protocol |
TRANSPORT_PARAMETER_ERROR (0x08) |
missing/invalid transport parameter, or a 0-RTT limit reduced (§2.3) |
| Version Negotiation packet returned, no handshake | client's QUIC version unsupported (§7.4) |
| Client Initial sent, nothing back at all | first datagram below the 1200-byte floor, or UDP blocked (§1.4) |
| Server stops after one flight, then a long pause | 3× anti-amplification limit reached; server waiting for a client packet (§8.1) |
Takeaways #
Most handshake debugging reduces to one move: find the CONNECTION_CLOSE, read its error
code, and (if it is in the 0x01xx range) subtract 0x0100 to recover the TLS alert.
The silent failures (no response, or a stall after one flight) are the exceptions, and
they trace to datagram size or the anti-amplification limit rather than to TLS. That
completes the handshake chapter; Chapter 3 turns to what happens after the
connection is up: how QUIC detects and recovers from loss.