Skip to content

Your First HTTP Request in C++

TCP delivers bytes; HTTP gives those bytes a request and response format. Send a GET request through raw socket calls and inspect the reply from a local HTTP server.

Course examples on GitHub

Open this lesson's sample · CMake build instructions.

Before you start

Complete Multiple TCP Connections. Use Raspberry Pi OS with Python 3.11 or later, C++20 and CMake. Run both terminals on the Pi in examples/network.

Outcome: identify the request line, headers, empty line and response body in an actual HTTP exchange.

The server is Python's standard-library test server, serving only the checked-in fixture directory. The client is standalone C++, not a wrapper around curl or an HTTP library.

What an HTTP GET sends over TCP

This is the request used on port 8080. Each displayed \r\n represents the two actual carriage-return and line-feed bytes in the C++ string:

1
2
3
4
GET /hello.txt HTTP/1.1\r\n
Host: 127.0.0.1:8080\r\n
Connection: close\r\n
\r\n
Part Purpose in this exchange
GET Ask for the selected resource.
/hello.txt Origin-form request target, not a local C++ file path.
HTTP/1.1 The request's protocol version.
Host The target authority, including this non-default port.
Empty line End of the header section. This GET has no request body.
Connection: close Ask to close this connection after the response.

The Host field does not connect a socket or perform DNS. Our connect() still explicitly targets IPv4 loopback. Different HTTP host authorities can share an address, so the application needs this field too.

We do not call shutdown(SHUT_WR) to finish the request. The empty header line ends this bodyless GET. A server can reply while the TCP connection remains open.

sequenceDiagram accTitle: HTTP messages inside one TCP connection accDescr: The C++ client connects, sends a GET line and headers ending with an empty line, and keeps its sending direction open. Python sends an HTTP status line, response headers and the file body, then closes because the request asks for Connection close. participant C as C++ client participant S as Local HTTP server C->>S: TCP connect C->>S: GET line, Host, Connection close C->>S: Empty header line Note over C: No write-side shutdown needed S->>C: Status line and response headers S->>C: Empty line, then file bytes S-->>C: Close after the response Note over C: Collect bytes until EOF for this raw capture

This shows logical message parts, not a fixed number of send(), recv() calls or TCP packets.

Start a temporary local HTTP server

Build the client:

cmake --preset native-debug
cmake --build --preset native-debug --parallel 2

In terminal A:

timeout 30s python3 -m http.server 8080 --bind 127.0.0.1 \
    --protocol HTTP/1.1 --directory app/http-wire/www

The directory contains only hello.txt, whose content is Hello from Raspberry Pi. followed by a newline. The process stops after 30 seconds; an exit status of 124 from timeout then means the time limit was reached. Restart it when needed.

Local test server only

Keep --bind 127.0.0.1 and the explicit fixture directory. Python's http.server is not a production server. Do not point this exercise at a directory containing private files or expose it to the internet.

Within 30 seconds, run in terminal B:

./build/native/debug/http_wire_client 8080

The output begins with HTTP/1.1 200 OK. It contains headers, an empty line and this body:

Hello from Raspberry Pi.

Expect Content-Length: 25 for the checked-in file. Server, Date and Last-Modified vary with the Python version, clock and checkout. Do not compare the entire header block against a fixed timestamp.

Separate the response parts

Part What to inspect
Status line Version and status code, such as 200.
Response headers Metadata, including content type and length.
Empty line Header/body boundary, encoded as \r\n\r\n.
Body File bytes, not the headers themselves.

Try an absent resource while the same server is running:

./build/native/debug/http_wire_client 8080 /missing

The server returns a 404 response with an error body. That is an HTTP response, not a failed TCP connection. This raw-capture client exits successfully if it collected a non-empty reply through EOF; it does not interpret the status code as success or failure.

Read the standalone client

Complete file: app/http-wire/client.cpp. Read it in three passes: construct the HTTP string, send all its bytes, then collect the response.

#include <algorithm>
#include <arpa/inet.h>
#include <array>
#include <cerrno>
#include <charconv>
#include <chrono>
#include <cstdint>
#include <iostream>
#include <poll.h>
#include <stdexcept>
#include <string>
#include <string_view>
#include <sys/socket.h>
#include <sys/time.h>
#include <system_error>
#include <unistd.h>

int main(int argc, char** argv) {
    int fd = -1;
    int status = 0;
    try {
        if (argc < 2 || argc > 4) {
            throw std::invalid_argument(
                "usage: http_wire_client port [path [receive_ms]]");
        }
        unsigned int port = 0;
        const std::string_view text = argv[1];
        const auto [end, error] =
            std::from_chars(text.data(), text.data() + text.size(), port);
        if (error != std::errc{} || end != text.data() + text.size() ||
            port == 0 || port > 65535) {
            throw std::invalid_argument("port must be 1..65535");
        }
        const std::string_view path = argc >= 3 ? argv[2] : "/hello.txt";
        if (path.empty() || path.front() != '/' || path.size() > 200 ||
            !std::all_of(path.begin(), path.end(), [](unsigned char c) {
                return c > 32 && c < 127 && c != '#';
            })) {
            throw std::invalid_argument("path must be a visible ASCII origin "
                                        "path without spaces or fragments");
        }
        int receive_ms = 3000;
        if (argc == 4) {
            const std::string_view budget = argv[3];
            const auto [last, invalid] = std::from_chars(
                budget.data(), budget.data() + budget.size(), receive_ms);
            if (invalid != std::errc{} ||
                last != budget.data() + budget.size() || receive_ms < 1 ||
                receive_ms > 10000) {
                throw std::invalid_argument("receive_ms must be 1..10000");
            }
        }
        fd = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
        if (fd < 0) {
            throw std::system_error(errno, std::generic_category(), "socket");
        }
        const timeval setup_timeout{3, 0};
        if (::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &setup_timeout,
                         sizeof(setup_timeout)) < 0) {
            throw std::system_error(errno, std::generic_category(),
                                    "setsockopt");
        }
        sockaddr_in server{};
        server.sin_family = AF_INET;
        server.sin_port = htons(static_cast<std::uint16_t>(port));
        server.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
        if (::connect(fd, reinterpret_cast<const sockaddr*>(&server),
                      sizeof(server)) < 0) {
            throw std::system_error(errno, std::generic_category(), "connect");
        }
        const std::string request =
            "GET " + std::string(path) +
            " HTTP/1.1\r\nHost: 127.0.0.1:" + std::to_string(port) +
            "\r\nConnection: close\r\n\r\n";
        std::size_t offset = 0;
        while (offset < request.size()) {
            const auto sent = ::send(fd, request.data() + offset,
                                     request.size() - offset, MSG_NOSIGNAL);
            if (sent < 0) {
                throw std::system_error(errno, std::generic_category(), "send");
            }
            if (sent == 0) {
                throw std::runtime_error("send made no progress");
            }
            offset += static_cast<std::size_t>(sent);
        }
        // HTTP's empty header line ends this bodyless GET, not TCP EOF.
        using Clock = std::chrono::steady_clock;
        const auto deadline =
            Clock::now() + std::chrono::milliseconds(receive_ms);
        std::array<char, 128> buffer{};
        std::string response;
        while (true) {
            const auto remaining = std::chrono::ceil<std::chrono::milliseconds>(
                                       deadline - Clock::now())
                                       .count();
            if (remaining <= 0) {
                throw std::runtime_error("receive deadline");
            }
            pollfd ready{fd, POLLIN, 0};
            const int result = ::poll(&ready, 1, static_cast<int>(remaining));
            if (result < 0) {
                if (errno == EINTR) {
                    continue;
                }
                throw std::system_error(errno, std::generic_category(), "poll");
            }
            if (result == 0 || Clock::now() >= deadline) {
                continue;
            }
            const auto received =
                ::recv(fd, buffer.data(), buffer.size(), MSG_DONTWAIT);
            if (received < 0) {
                if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
                    continue;
                }
                throw std::system_error(errno, std::generic_category(), "recv");
            }
            if (received == 0) {
                break;
            }
            response.append(buffer.data(), static_cast<std::size_t>(received));
            if (response.size() > 8192) {
                throw std::runtime_error("response exceeds 8192 bytes");
            }
        }
        if (response.empty()) {
            throw std::runtime_error("empty response");
        }
        // Raw capture only: no HTTP status or message-length validation yet.
        std::cout.write(response.data(), response.size());
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

The port and path checks stop malformed arguments from adding request-header lines. Paths in this small example are limited to 200 visible ASCII characters, start with / and contain no spaces or fragment marker. They are not a complete URL parser.

The receive phase has a fixed three-second budget and an 8192-byte response cap. Linux setup waits have separate per-call timeouts. No project-local header hides the socket operations or descriptor cleanup.

To build just this file:

1
2
3
mkdir -p build/direct
c++ -std=c++20 -Wall -Wextra -Wpedantic \
    app/http-wire/client.cpp -o build/direct/http_wire_client

Why this is not an HTTP parser yet

The client prints the response unchanged. It does not validate a status line, decode chunked transfer coding or prove that a declared body length arrived.

For this first capture, we ask the server to close and read until EOF. A complete response with a Content-Length does not generally require EOF, and a premature EOF can leave an incomplete body. The next lesson adds those distinctions rather than mistaking a successful socket read for a valid HTTP message.

If a test peer sends the complete response but keeps the connection open, this raw client reaches its receive deadline. That limitation is deliberate and tested.

This exchange is plain HTTP, without encryption or server authentication. HTTPS will add TLS in the next chapter; it is not achieved by changing a port number.

Troubleshooting and checks

  • Connection refused: restart terminal A after its 30-second window and check the same port in both commands.
  • Port already in use: choose another unprivileged port and update both commands. The C++ client builds the matching Host field.
  • The existing file returns 404: run the server from examples/network with the exact --directory argument.
  • Receive deadline: the peer did not close before the budget. The raw capture has not yet learned HTTP body framing.
  • Different headers: normal for generated dates and version strings. Inspect the status and file content first.

Run the lesson's checks:

ctest --preset native-debug -R '^network[.]http_wire[.]' --output-on-failure

They include a real Python HTTP/1.1 server, a 404 response, exact request bytes, split replies, binary capture, input validation, a response-size cap and a non-renewing receive deadline. No public internet server is used.

Check your understanding

The C++ client has sent the final \r\n\r\n for this bodyless GET. Must it close its TCP sending direction before the server can reply?

Compare your reasoning

No. HTTP message syntax already identifies this request's end. TCP EOF is not required. The request and response each have their own HTTP framing rules.

Continue the course

Previous: Multiple TCP Connections · Next: HTTP Headers and Body Length · Course overview

References: HTTP/1.1 message syntax, HTTP semantics, Python's test HTTP server.

Donate