Skip to content

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.

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

flowchart TB accTitle: HTTP semantics mapped onto QUIC streams accDescr: The C++ application gives libcurl a GET URL. The HTTP/3 implementation encodes request and response fields with QPACK, carries HEADERS and DATA on a request stream, and maintains separate control streams. QUIC supplies the protected stream transport over UDP. A[C++ application: GET an HTTPS URL] --> H[libcurl HTTP/3 implementation] H --> R[Request stream: HEADERS and DATA] H --> C[Separate control streams: SETTINGS] H --> P[QPACK field encoding and decoding] R --> Q[QUIC connection: verified TLS handshake and streams] C --> Q P --> R Q --> U[UDP and IP]

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:

1
2
3
apt-cache policy libcurl4-openssl-dev
sudo apt install libcurl4-openssl-dev pkg-config
curl -V

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:

1
2
3
cmake --preset native-debug -DNETWORK_BUILD_QUIC=ON -DNETWORK_BUILD_HTTP3=ON
cmake --build --preset native-debug --target http3_client --parallel 2
./build/native/debug/http3_client --features

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:

libcurl=8.14.1 http3=yes
quic_backend=nghttp3/1.8.0

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:

1
2
3
http3_practice=$(mktemp -d /tmp/network-http3-practice.XXXXXX)
python3 fixtures/tls_echo.py certs "$http3_practice/certs"
printf '%s\n' "$http3_practice/certs"

Use the Python environment containing aioquic to start the fixture. Replace this placeholder with the complete virtual-environment path created earlier:

http3_python="/paste/the/virtual-environment/bin/python"
"$http3_python" fixtures/http3_server.py 9450 "$http3_practice/certs"

After LISTEN 9450, run in terminal B. Replace the placeholder with the complete printed certificate-directory path; shell variables are not shared across terminals:

http3_certs="/paste/the/printed/certificate-directory"
./build/native/debug/http3_client 9450 "$http3_certs/ca.pem"

Expected output:

http_version=3 status=200 body_bytes=13
hello HTTP/3

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:

1
2
3
4
5
http3_direct=$(mktemp -d /tmp/network-http3-direct.XXXXXX)
c++ -std=c++20 -Wall -Wextra -Wpedantic -Werror \
    app/http3/client.cpp $(pkg-config --cflags --libs libcurl) \
    -o "$http3_direct/http3_client"
"$http3_direct/http3_client" --features
Full standalone client: app/http3/client.cpp
#include <curl/curl.h>

#include <algorithm>
#include <array>
#include <charconv>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <string_view>

struct Body {
    std::array<char, 16384> bytes{};
    std::size_t used = 0;
    bool exceeded = false;
};

// Callbacks must not throw through the C library, or assume NUL termination.
std::size_t receive_body(char* data, std::size_t size, std::size_t count,
                         void* opaque) noexcept {
    auto& body = *static_cast<Body*>(opaque);
    if (size != 0 && count > std::numeric_limits<std::size_t>::max() / size) {
        body.exceeded = true;
        return 0;
    }
    const auto length = size * count;
    if (length > body.bytes.size() - body.used) {
        body.exceeded = true;
        return 0;
    }
    std::copy_n(data, length, body.bytes.data() + body.used);
    body.used += length;
    return length;
}

std::size_t receive_header(char*, std::size_t size, std::size_t count,
                           void* opaque) noexcept {
    auto& used = *static_cast<std::size_t*>(opaque);
    if (size != 0 && count > std::numeric_limits<std::size_t>::max() / size) {
        return 0;
    }
    const auto length = size * count;
    if (length > 16384 - used) {
        return 0;
    }
    used += length;
    return length;
}

void check(CURLcode code) {
    if (code != CURLE_OK) {
        throw std::runtime_error(curl_easy_strerror(code));
    }
}

int number(std::string_view text, int maximum) {
    int value = 0;
    auto [end, error] =
        std::from_chars(text.data(), text.data() + text.size(), value);
    if (error != std::errc{} || end != text.data() + text.size() || value < 1 ||
        value > maximum) {
        throw std::runtime_error("invalid port or session_ms");
    }
    return value;
}

int main(int argc, char** argv) {
    const bool features =
        argc == 2 && std::string_view(argv[1]) == "--features";
    if (!features && (argc < 3 || argc > 5)) {
        std::cerr << "usage: http3_client port ca.pem [hostname [session_ms]]\n"
                     "       http3_client --features\n";
        return 2;
    }
    CURL* client = nullptr;
    curl_slist* addresses = nullptr;
    bool initialized = false;
    int status = 1;
    try {
        check(curl_global_init(CURL_GLOBAL_DEFAULT));
        initialized = true;
        const auto* version = curl_version_info(CURLVERSION_NOW);
        if (!version || version->version_num < 0x075800 ||
            !(version->features & CURL_VERSION_HTTP3)) {
            throw std::runtime_error(
                "loaded libcurl has no HTTP/3 support (7.88+ required)");
        }
        if (features) {
            std::cout << "libcurl=" << version->version << " http3=yes\n";
            if (version->age >= CURLVERSION_SIXTH && version->quic_version) {
                std::cout << "quic_backend=" << version->quic_version << '\n';
            }
            status = 0;
        } else {
            const int port = number(argv[1], 65535);
            const std::string hostname = argc >= 4 ? argv[3] : "localhost";
            if (hostname.empty() || hostname.size() > 253 ||
                !std::all_of(hostname.begin(), hostname.end(), [](char c) {
                    return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
                           (c >= '0' && c <= '9') || c == '.' || c == '-';
                })) {
                throw std::runtime_error("expected a simple DNS hostname");
            }
            const long budget = argc == 5 ? number(argv[4], 10000) : 3000;
            const std::string authority = hostname + ":" + std::to_string(port);
            const std::string url = "https://" + authority + "/";
            const std::string resolution = authority + ":127.0.0.1";
            addresses = curl_slist_append(nullptr, resolution.c_str());
            client = curl_easy_init();
            if (!addresses || !client) {
                throw std::runtime_error("client allocation failed");
            }
            Body body;
            std::size_t header_bytes = 0;
            check(curl_easy_setopt(client, CURLOPT_URL, url.c_str()));
            check(curl_easy_setopt(client, CURLOPT_RESOLVE, addresses));
            check(curl_easy_setopt(client, CURLOPT_IPRESOLVE,
                                   static_cast<long>(CURL_IPRESOLVE_V4)));
            check(curl_easy_setopt(client, CURLOPT_PROXY, ""));
            check(curl_easy_setopt(client, CURLOPT_NOPROXY, "*"));
            check(curl_easy_setopt(client, CURLOPT_PROTOCOLS_STR, "https"));
            check(curl_easy_setopt(client, CURLOPT_FOLLOWLOCATION, 0L));
            check(curl_easy_setopt(client, CURLOPT_HTTPGET, 1L));
            check(curl_easy_setopt(client, CURLOPT_HTTP_VERSION,
                                   static_cast<long>(CURL_HTTP_VERSION_3ONLY)));
            check(curl_easy_setopt(client, CURLOPT_SSL_VERIFYPEER, 1L));
            check(curl_easy_setopt(client, CURLOPT_SSL_VERIFYHOST, 2L));
            check(curl_easy_setopt(client, CURLOPT_CAINFO, argv[2]));
            check(curl_easy_setopt(client, CURLOPT_NOSIGNAL, 1L));
            check(curl_easy_setopt(client, CURLOPT_CONNECTTIMEOUT_MS, budget));
            check(curl_easy_setopt(client, CURLOPT_TIMEOUT_MS, budget));
            check(curl_easy_setopt(client, CURLOPT_MAXFILESIZE_LARGE,
                                   static_cast<curl_off_t>(body.bytes.size())));
            check(curl_easy_setopt(client, CURLOPT_HTTP_CONTENT_DECODING, 0L));
            check(
                curl_easy_setopt(client, CURLOPT_WRITEFUNCTION, receive_body));
            check(curl_easy_setopt(client, CURLOPT_WRITEDATA, &body));
            check(curl_easy_setopt(client, CURLOPT_HEADERFUNCTION,
                                   receive_header));
            check(curl_easy_setopt(client, CURLOPT_HEADERDATA, &header_bytes));

            // A fresh handle plus 3ONLY avoids reusing an older-protocol
            // connection.
            const auto transfer = curl_easy_perform(client);
            if (body.exceeded) {
                throw std::runtime_error("body exceeds 16384 bytes");
            }
            check(transfer);
            long protocol = 0;
            long response = 0;
            check(curl_easy_getinfo(client, CURLINFO_HTTP_VERSION, &protocol));
            check(curl_easy_getinfo(client, CURLINFO_RESPONSE_CODE, &response));
            if (protocol != CURL_HTTP_VERSION_3 || response != 200) {
                throw std::runtime_error(
                    "expected HTTP/3 status 200, got status " +
                    std::to_string(response));
            }
            std::cout << "http_version=3 status=200 body_bytes=" << body.used
                      << '\n';
            std::cout.write(body.bytes.data(),
                            static_cast<std::streamsize>(body.used));
            status = 0;
        }
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
    }
    curl_easy_cleanup(client);
    curl_slist_free_all(addresses);
    if (initialized) {
        curl_global_cleanup();
    }
    return status;
}

Test protocol and failure boundaries

ctest --preset native-debug -R '^network\.http3\.' --output-on-failure

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

  1. Why is HTTP/1.1 request text on a custom QUIC stream not HTTP/3?
  2. What does 3ONLY prove that an ordinary HTTPS URL does not?
  3. Why does status 200 alone not prove the full body was received successfully?
Answers
  1. HTTP/3 needs its defined stream mapping, binary frames, control state and QPACK, negotiated as h3.
  2. This transfer must use HTTP/3 rather than succeed through an earlier HTTP version. The runtime feature and completed-transfer version are checked too.
  3. 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.

Donate