Your First HTTP/3 Request in C++¶
HTTP/3 adds HTTP requests and responses to QUIC streams. Make a real HTTPS request to a local HTTP/3 server, verify its identity, and reject silent fallback to an earlier HTTP version.
Course examples on GitHub
Standalone C++ client · Independent HTTP/3 fixture · Protocol and failure checks.
Outcome: Receive status 200 and hello HTTP/3 through HTTP/3, with verified localhost identity and a fixed transfer deadline. A TCP-only endpoint must fail this exercise rather than succeed through HTTP/1.1 or HTTP/2.
Before you start¶
Complete Multiple QUIC Streams and Your First HTTPS Request. Run commands on Linux or your Pi from the examples checkout's network/ directory.
You need C++20, CMake 3.28+, libcurl 7.88+ development files and an HTTP/3-enabled libcurl runtime, plus the pinned Python/aioquic environment from the QUIC lessons. The runtime feature is essential: a newer version number or an OpenSSL entry alone is not enough.
This lesson intentionally uses libcurl for the HTTP/3 implementation. The previous two pages expose QUIC socket and stream management directly. Here the library handles HTTP framing, QPACK and its QUIC integration; this is not another hand-written socket parser or an implementation of HTTP/3 from scratch. There is still one standalone C++ file, with no course-specific wrapper or net.hpp.
HTTP/3 is more than text on a QUIC stream¶
The request's logical fields are :method = GET, :scheme = https, :authority = localhost:9450 and :path = /. They are not an ASCII GET / HTTP/1.1 line. A bidirectional request stream carries the request and its response; HTTP/3 HEADERS contain QPACK-encoded fields, and DATA carries body bytes. The response status is represented by :status, not a textual HTTP/1.1 status line.
Each endpoint also maintains its own unidirectional control stream, beginning with SETTINGS. QPACK can additionally use encoder/decoder streams; do not assume every HTTP/3 connection needs dynamic-table traffic. Changing the echo example's ALPN string to h3 would implement none of this. See HTTP/3 stream mapping and QPACK.
HTTP semantics remain familiar — methods, fields, status and bodies — while the representation and transport differ. HTTPS is still HTTPS when it uses HTTP/3 rather than HTTP/1.1 over TLS/TCP.
Check HTTP/3 support separately¶
On Raspberry Pi OS, inspect the development package candidate before installing missing dependencies:
Look for HTTP3 in the command's feature list. The C++ program will also check the library it actually loads, which can differ from the command-line curl installation. Do not replace system libraries manually or disable verification to make a probe pass.
With the Python test environment already configured from the previous lesson:
The cached NETWORK_QUIC_PYTHON must still point to the virtual environment containing tests/requirements-quic.txt. On a fresh build, pass it explicitly as in the QUIC setup. For C++-only builds, -DBUILD_TESTING=OFF avoids the independent Python peer but does not count as running the tests. HTTP/3 is a separate opt-in: earlier lessons can still build without libcurl development files.
Example feature output from the inspected setup:
The version/backend line can differ. nghttp3 identifies HTTP/3 support machinery, not by itself every underlying QUIC/TLS component. The strict runtime check rejects an unsupported library before performing a transfer. See libcurl runtime feature reporting.
Verification scope and backend maturity
The course checks used Debian's security-patched libcurl 8.14.1 package, with OpenSSL 3.5 and nghttp3, isolated in scratch space on host and Pi. The host's original curl lacked HTTP/3. The native OpenSSL QUIC backend was marked experimental in curl 8.14.1; these are bounded learning/interoperability checks, not a production-backend recommendation. Check maintained packages and the documentation for that curl release, rather than assuming all libcurl builds have identical capabilities or maturity.
Run a verified local HTTP/3 request¶
The independent fixture uses aioquic's QUIC and HTTP/3 implementations, not Python's ssl module or a TCP HTTP server. Prepare terminal B before starting its bounded listener.
In terminal A:
Use the Python environment containing aioquic to start the fixture. Replace this placeholder with the complete virtual-environment path created earlier:
After LISTEN 9450, run in terminal B. Replace the placeholder with the complete printed certificate-directory path; shell variables are not shared across terminals:
Expected output:
The payload is 13 bytes including its newline. The client accepts only status 200 and prints the body only after the transfer and protocol/status checks succeed. Restart the fixture for another run; it exits after an observed connection termination or its 15-second wait. WAIT_EXPIRED is a normal bounded-fixture exit, not a claim that the request failed: the response can complete without the fixture observing a peer close notification. The optional --session-ms controls that wait (100–15000 ms).
The CA remains process-local and its practice certificates expire after two days. Server authentication does not authenticate a client. Both peers are local teaching tools, not public services.
Read the C++ policies¶
| Policy | Where it is enforced |
|---|---|
| Only a loopback destination | A fixed HTTPS URL shape and CURLOPT_RESOLVE map the selected hostname/port to 127.0.0.1 |
| Preserve the hostname for identity checks | The URL keeps localhost; it is not replaced with a numeric URL and verification disabled |
| No proxy or redirect escape | Proxy use is disabled explicitly, protocols are restricted to HTTPS, and redirects are not followed |
| No older HTTP fallback | A fresh handle uses CURL_HTTP_VERSION_3ONLY; completed transfers must also report CURL_HTTP_VERSION_3 |
| Verify the server | Explicit CA file, SSL_VERIFYPEER = 1 and SSL_VERIFYHOST = 2 |
| Bound the whole transfer | CURLOPT_TIMEOUT_MS, with the connection timeout inside that same budget |
| Bound application output | A fixed 16384-byte body array, a maximum-file-size policy, and a separate 16384-byte delivered-header budget |
The optional arguments are port ca.pem [hostname [session_ms]]. Defaults are localhost and 3000 ms; budgets accept 1–10000 ms. A simple alternate DNS hostname is allowed for negative identity experiments, but the address is still forced to IPv4 loopback. Receiving periodic bytes does not restart the transfer budget. See HTTP version policy and transfer timeout.
The body callback receives decoded protocol body bytes, not QUIC packets, HTTP/3 frames or NUL-terminated strings. Content decompression is disabled: the example does not promise to decode arbitrary content encodings. It checks multiplication and remaining capacity before copying, never throws through the C callback boundary, and never assumes callback boundaries are messages. The header callback counts libcurl's delivered header representation, not QPACK wire size. These are application storage limits, not an exhaustive bound on all internal library allocations. See write callbacks.
curl_easy_perform() drives HTTP/3 processing and returns an error for timeout or failed transfer. Merely seeing body bytes, a matching Content-Length, or status 200 is not enough: the transfer must complete successfully. HTTP/3 does not use HTTP/1.1 chunked transfer coding. This page delegates protocol validation to the maintained implementation rather than reusing the earlier text parser.
Full standalone client¶
For a normal installed HTTP/3-enabled libcurl development setup, direct compilation is also possible:
Full standalone client: app/http3/client.cpp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 | |
Test protocol and failure boundaries¶
Independent aioquic peers check the GET pseudo-fields, one request only, split/binary/empty bodies, exact and exceeded limits, missing Content-Length, truncation, missing FIN, resets, early closure, trust/hostname/ALPN failures, invalid arguments, proxy environment variables, redirects and non-200 status. A TCP-only fixture must receive no TCP connection, demonstrating that this experiment does not silently downgrade. The host's original non-HTTP/3 library is also checked separately for failure before UDP output.
| Failure | What to do |
|---|---|
loaded libcurl has no HTTP/3 support |
Check the loaded runtime, not only curl -V. Install a maintained matching development/runtime setup or keep the optional chapter disabled |
| Certificate or hostname error | Use the correct fresh CA and hostname; do not turn off peer/host verification |
| Timeout with a TCP-only server | Expected here. HTTP/3 uses QUIC/UDP; this exercise intentionally disallows fallback |
expected HTTP/3 status 200 |
Inspect the HTTP status. A 404 or redirect is not this exercise's successful result |
| Body/header size failure | The response exceeded this teaching profile. Do not treat a retained prefix as a complete success |
In production, controlled fallback can be useful, but it would obscure what this lesson proves. This sample makes one request, does not exercise reuse/multiple HTTP requests, discovery via Alt-Svc, resumption, 0-RTT or public-server operation, and makes no performance claim. A failed response does not prove a server-side action was rolled back.
Check your understanding¶
- Why is HTTP/1.1 request text on a custom QUIC stream not HTTP/3?
- What does
3ONLYprove that an ordinary HTTPS URL does not? - Why does status 200 alone not prove the full body was received successfully?
Answers
- HTTP/3 needs its defined stream mapping, binary frames, control state and QPACK, negotiated as
h3. - This transfer must use HTTP/3 rather than succeed through an earlier HTTP version. The runtime feature and completed-transfer version are checked too.
- Data can be truncated, reset or timed out after the headers arrive. The implementation must report successful transfer completion within the limits.
Continue the course¶
This completes the first QUIC/HTTP/3 learning path. Return to the Network overview to choose an experiment: UDP delivery, TCP framing, verified HTTPS, WebSocket messages, DTLS application retries, or independent QUIC stream progress.