Skip to content

Reuse an HTTP/1.1 Connection in C++

A complete HTTP response does not necessarily end its TCP connection. Send two requests in sequence through one socket, finish each declared body and close only after the second response.

Before you start

Complete Chunked HTTP Bodies, and review the Content-Length client. Use two Pi terminals in examples/network.

Outcome: complete two independently framed responses on one connection, without confusing the first body's end with TCP EOF or silently retrying a failed request.

This exercise deliberately reuses the earlier Content-Length profile. It does not combine every HTTP framing mode into a general client.

One connection, two message lifetimes

sequenceDiagram accTitle: Finish the first body before sending the second request accDescr: The client connects once. It sends GET hello.txt, reads the status, headers and exact first body, then sends GET missing on the same connection. After reading the second response, it closes. The server is not required to close between messages. participant C as C++ client participant S as HTTP/1.1 server C->>S: One TCP connect C->>S: GET /hello.txt, keep-alive S->>C: First headers and Content-Length body Note over C: Body complete, socket stays open C->>S: GET /missing, Connection close S->>C: Second headers and Content-Length body Note over C: Second body complete C-->>S: Close our socket

This is sequential reuse, not pipelining. The client does not send request two before response one is complete. There is never more than one outstanding request.

HTTP/1.1 connections are persistent by default when message framing permits it and no close option applies. The first request's explicit Connection: keep-alive makes the demonstration's intent visible; it is not what creates the HTTP/1.1 default, nor a promise that the server will keep the socket open.

Run against the existing file fixture

Build:

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

Terminal A:

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

Terminal B:

./build/native/debug/http_reuse_client 8080

The output begins:

response=1 status=200 bytes=25
Hello from Raspberry Pi.

Then comes response=2 status=404 bytes=... and Python's error body for /missing. The second response's length is calculated from its received header; its generated HTML can vary with the fixture version.

Both requests use the same descriptor and the same successful connect(). No new connection is opened for the missing resource.

Use the Python HTTP/1.1 fixture here, not the one-request C++ server from lesson 12. That server intentionally sends Connection close and exits after one request. A reuse failure against it is expected.

Keep response state separate from socket state

Survives across both requests Recreated for each response
Connected descriptor fd Header-complete flag and header boundary.
Remote loopback address and authority Selected status and body length.
Explicit final cleanup Response bytes and field-validation flags.
No reconnect/retry policy Fixed receive-phase deadline.

The parser finishes the complete first body before discarding its response storage. It does not drop an incomplete body just because another request is ready to send.

This sequential profile rejects surplus bytes already buffered after a response. Before request two, a nonblocking peek also rejects bytes already queued without a corresponding request. It never treats those bytes as an unsolicited second response.

A pipelining implementation would need outstanding-request tracking and careful ownership of unread bytes. This client deliberately does not implement that design.

Read the standalone client

Read the outer for loop separately from the inner receive loop:

  1. Connect once, outside the request loop.
  2. Send the selected GET completely.
  3. Accumulate and validate this response's headers and exact Content-Length body.
  4. Before reuse, check that the peer did not request close and that no known surplus or EOF is waiting.
  5. Advance to the second request, then clean up the single descriptor.
Full standalone client: app/http-reuse/client.cpp
#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 > 3) {
            throw std::invalid_argument(
                "usage: http_reuse_client port [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");
        }
        int receive_ms = 3000;
        if (argc == 3) {
            const std::string_view budget = argv[2];
            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::array<std::string_view, 2> paths{"/hello.txt", "/missing"};
        for (std::size_t index = 0; index < paths.size(); ++index) {
            const auto path = paths[index];
            const std::string request =
                "GET " + std::string(path) +
                " HTTP/1.1\r\nHost: 127.0.0.1:" + std::to_string(port) +
                "\r\nConnection: " +
                std::string(index == 0 ? "keep-alive" : "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, 16> buffer{};
            std::string response;
            bool headers_done = false;
            std::size_t body_start = 0;
            std::size_t length = 0;
            int http_status = 0;
            bool peer_close = false;
            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 wanted =
                    headers_done ? std::min(buffer.size(), body_start + length -
                                                               response.size())
                                 : buffer.size();
                const auto received =
                    ::recv(fd, buffer.data(), wanted, 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) {
                    throw std::runtime_error(
                        headers_done ? "truncated body" : "truncated headers");
                }
                response.append(buffer.data(),
                                static_cast<std::size_t>(received));
                if (!headers_done) {
                    const auto boundary = response.find("\r\n\r\n");
                    if (boundary == std::string::npos) {
                        if (response.size() > 4096) {
                            throw std::runtime_error(
                                "headers exceed 4096 bytes");
                        }
                        continue;
                    }
                    body_start = boundary + 4;
                    if (body_start > 4096) {
                        throw std::runtime_error("headers exceed 4096 bytes");
                    }
                    const auto first_end = response.find("\r\n");
                    const std::string_view status_line(response.data(),
                                                       first_end);
                    if (status_line.size() < 13 ||
                        !status_line.starts_with("HTTP/1.1 ") ||
                        status_line[12] != ' ' ||
                        !std::all_of(status_line.begin(), status_line.end(),
                                     [](unsigned char c) {
                                         return c == '\t' ||
                                                (c >= 32 && c < 127);
                                     })) {
                        throw std::runtime_error("invalid status line");
                    }
                    const auto code = status_line.substr(9, 3);
                    if (code != "200" && code != "404") {
                        throw std::runtime_error(
                            "status outside this lesson's 200/404 profile");
                    }
                    http_status = code == "200" ? 200 : 404;
                    bool length_seen = false;
                    bool connection_seen = false;
                    std::size_t cursor = first_end + 2;
                    while (cursor < boundary) {
                        const auto line_end = response.find("\r\n", cursor);
                        const std::string_view line(response.data() + cursor,
                                                    line_end - cursor);
                        const auto colon = line.find(':');
                        if (colon == std::string_view::npos || colon == 0) {
                            throw std::runtime_error("invalid header line");
                        }
                        std::string name(line.substr(0, colon));
                        const std::string_view punctuation =
                            "!#$%&'*+-.^_\x60|~";
                        if (!std::all_of(name.begin(), name.end(),
                                         [punctuation](unsigned char c) {
                                             return (c >= 'a' && c <= 'z') ||
                                                    (c >= 'A' && c <= 'Z') ||
                                                    (c >= '0' && c <= '9') ||
                                                    punctuation.find(c) !=
                                                        std::string_view::npos;
                                         })) {
                            throw std::runtime_error("invalid header name");
                        }
                        for (auto& c : name) {
                            if (c >= 'A' && c <= 'Z') {
                                c += 'a' - 'A';
                            }
                        }
                        auto value = line.substr(colon + 1);
                        if (!std::all_of(value.begin(), value.end(),
                                         [](unsigned char c) {
                                             return c == '\t' ||
                                                    (c >= 32 && c < 127);
                                         })) {
                            throw std::runtime_error("invalid header value");
                        }
                        while (!value.empty() && (value.front() == ' ' ||
                                                  value.front() == '\t')) {
                            value.remove_prefix(1);
                        }
                        while (!value.empty() &&
                               (value.back() == ' ' || value.back() == '\t')) {
                            value.remove_suffix(1);
                        }
                        if (name == "transfer-encoding" ||
                            name == "content-encoding") {
                            throw std::runtime_error("encoded response outside "
                                                     "this lesson's profile");
                        }
                        if (name == "connection") {
                            std::string option(value);
                            for (auto& c : option) {
                                if (c >= 'A' && c <= 'Z') {
                                    c += 'a' - 'A';
                                }
                            }
                            if (connection_seen ||
                                (option != "close" && option != "keep-alive")) {
                                throw std::runtime_error(
                                    "Connection field outside this profile");
                            }
                            connection_seen = true;
                            peer_close = option == "close";
                        }
                        if (name == "content-length") {
                            if (length_seen) {
                                throw std::runtime_error(
                                    "duplicate Content-Length");
                            }
                            const auto [last, invalid] = std::from_chars(
                                value.data(), value.data() + value.size(),
                                length);
                            if (invalid != std::errc{} ||
                                last != value.data() + value.size() ||
                                length > 4096) {
                                throw std::runtime_error(
                                    "invalid or oversized Content-Length");
                            }
                            length_seen = true;
                        }
                        cursor = line_end + 2;
                    }
                    if (!length_seen) {
                        throw std::runtime_error("missing Content-Length");
                    }
                    headers_done = true;
                }
                if (response.size() - body_start >= length) {
                    break; // HTTP framing, not peer EOF, completes this body.
                }
            }
            if (response.size() != body_start + length) {
                throw std::runtime_error("unsolicited surplus data");
            }
            if (index == 0 && peer_close) {
                throw std::runtime_error(
                    "peer requested close, refusing reuse");
            }
            std::cout << "response=" << index + 1 << " status=" << http_status
                      << " bytes=" << length << '\n';
            std::cout.write(response.data() + body_start, length);
            if (index == 0) {
                char byte = 0;
                const auto pending =
                    ::recv(fd, &byte, 1, MSG_PEEK | MSG_DONTWAIT);
                if (pending >= 0) {
                    throw std::runtime_error(
                        pending == 0 ? "peer closed before reuse"
                                     : "unsolicited bytes before next request");
                }
                if (errno != EAGAIN && errno != EWOULDBLOCK) {
                    throw std::system_error(errno, std::generic_category(),
                                            "probe reuse");
                }
            }
        }
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

Compile directly:

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

The parser accepts HTTP/1.1 status 200/404, ASCII headers up to 4096 bytes, one Content-Length of 0–4096 body bytes and no transfer/content coding. Connection may be absent or contain one keep-alive or close option; duplicate fields and more complex option lists are outside this profile.

General HTTP permits more cases. This is a loopback teaching client, not a connection pool or a production response parser.

Handle closure without inventing a successful retry

A server may close a persistent connection. Several observations differ:

Observation This client does
First response says Connection close Refuse to reuse, even if its body is complete.
EOF is already visible before request two Stop rather than reconnect.
Send or receive fails during request two Report the error and close.
Second body's declared bytes are incomplete Report truncation or the receive deadline.
Both bodies complete Return success and close our descriptor.

The nonblocking MSG_PEEK probe is only a check of what is visible now. A would-block result is not a guarantee of future connection health; closure can happen immediately afterwards.

There is no automatic resend. A failed response does not necessarily prove the peer failed to perform an operation. Retry policy belongs to application semantics, especially for state-changing requests. Do not add an unconditional reconnect loop to hide transport errors.

The optional second argument changes the per-response receive budget, in milliseconds:

./build/native/debug/http_reuse_client 8080 1000

A new budget starts after each request is sent. This is not one shared deadline for both requests, nor a bound on total setup/send time. Each completed body still needs correct message framing.

Verify reuse rather than just two successful pages

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

The fourteen cases include the actual Python server and an independent peer that receives both requests on one accepted socket. Before replying to the first request, that peer checks that the client has not already queued another request.

Other cases cover one-byte peer writes, an empty first body, a truncated second body, Connection close, unsolicited bytes, missing/duplicate lengths, an unsupported second status, waits that expire in either response, invalid arguments and connection refusal. The fixtures also reject an extra accepted connection, so a hidden reconnect does not count as reuse.

These are correctness checks, not latency or throughput benchmarks. No claim about a particular number of TCP packets follows from them.

Check your understanding

The first body is complete, but its header says Connection: close. May the client send another request because the socket has not returned EOF yet?

Compare your reasoning

No. The application-level close option already rules out reuse of this connection. Even without that option, an open socket check would not guarantee the next request succeeds.

Continue the course

Previous: Chunked HTTP Bodies · Next: Your First Verified TLS Connection

References: HTTP/1.1 persistence, response association, connection-specific fields.

Donate