Skip to content

HTTP Headers and Content-Length in C++

A received TCP chunk is not an HTTP response. Find the end of the headers, validate the body's declared byte count and stop when those bytes have arrived—even while the peer is still connected.

Course examples on GitHub

Open this lesson's sample · CMake build instructions.

Before you start

Complete Your First HTTP Request. Use the same local file fixture and two Pi terminals in examples/network.

Outcome: extract exactly the declared HTTP body, retain split header bytes and distinguish an incomplete response from a completed one.

This client deliberately supports a small response profile. It is not a general HTTP implementation or a replacement for a maintained HTTP library.

Two boundaries, two different jobs

Boundary How this client finds it
End of response headers Search accumulated bytes for \r\n\r\n.
End of the selected response body Count Content-Length bytes after that header terminator.

For a declared length of five, the body could arrive as he, then llo. Header bytes might also arrive in pieces, and the read containing the terminator may already contain part of the body.

The length counts body bytes, not characters and not the entire response. A UTF-8 character may occupy multiple bytes. The empty line itself is not part of the body count.

sequenceDiagram accTitle: A complete HTTP body does not need TCP EOF accDescr: The peer sends part of a header, then the rest including Content-Length five and the header terminator, along with he. The client retains everything and waits for llo. With all five body bytes available, it returns hello and closes its own socket without waiting for peer EOF. participant S as Test peer participant C as C++ client S->>C: Status line and partial header Note over C: No terminator yet, retain bytes S->>C: Content-Length 5, empty line, he Note over C: Headers complete, body has 2 of 5 bytes S->>C: llo Note over C: Body has 5 of 5 bytes, complete Note over S: Sending direction still open C-->>S: Client closes its own socket

The parts shown are logical byte ranges, not promised recv() boundaries. The sample uses a 16-byte receive buffer so a normal header necessarily spans reads.

State the supported HTTP profile first

This lesson accepts responses to its own bodyless GET with:

Supported choice Limit or rule
Response version HTTP/1.0 or HTTP/1.1.
Status 200 or 404 only.
Header section At most 4096 bytes, including the terminator.
Body One Content-Length, decimal 0–4096 bytes.
Header spelling Case-insensitive field names and optional surrounding spaces/tabs in values.
Other header syntax Valid ASCII token names and visible ASCII/tab values, no folded lines.
Transfer/content coding Neither Transfer-Encoding nor Content-Encoding is accepted.
Connection handling One response, then close our socket; no reuse or pipelining.

Rejecting identical duplicate lengths, encoded responses and some otherwise valid HTTP messages is deliberately stricter than full HTTP. It makes the accepted profile explicit.

Content-Length is not the universal HTTP body rule

Responses to HEAD, informational responses, 204 and 304 have special rules. Chunked transfer coding and close-delimited bodies have different framing. This exercise rejects those cases instead of applying this simplified length algorithm to them.

Content-Encoding, such as gzip, concerns representation coding; it is not the same as Transfer-Encoding. This lesson implements neither decoding path. Do not use this small parser as an internet-facing client, proxy or security boundary.

Build and run the length-aware client

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

Restart the same server in terminal A:

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

In terminal B:

./build/native/debug/http_length_client 8080

Expected output:

status=200 bytes=25
Hello from Raspberry Pi.

The summary line is printed by our client. The following bytes are the file body, without response headers.

Try a missing path:

./build/native/debug/http_length_client 8080 /missing

Expect status=404 bytes=... followed by Python's error body. A valid 404 is still an HTTP response; exit status zero here means the response matched the supported profile and its complete body arrived, not that the resource existed.

Follow the parser in five passes

  1. Accumulate headers. Search after each read, not just in the newest chunk. Enforce the header cap even if the terminator never arrives.
  2. Validate the status and fields. Check the response version and this lesson's status set. Reject malformed names, folded lines and control characters.
  3. Read one length. Normalize field names, trim optional spaces/tabs, then require a complete unsigned decimal value within the body cap. Reject duplicate lengths and unsupported encodings.
  4. Keep body bytes already read. Set body_start just after the terminator. Do not throw away the bytes that followed it in the same read.
  5. Collect only what remains. Once the length is known, limit each read to the remaining body count and stop when it reaches zero.

The header parser uses temporary string_view values only while the response string is unchanged. Later reads can reallocate that string, so it does not retain views across appends.

A single fixed receive deadline covers both header and body collection. Incoming bytes do not restart it. Setup calls retain separate Linux per-call timeouts, so this is not a whole-request deadline.

Read the complete standalone file

Complete file: app/http-length/client.cpp. Setup and sending match the preceding lesson; the added work is header validation and body framing. All socket operations and cleanup remain in the file.

Full standalone client: app/http-length/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 > 4) {
            throw std::invalid_argument(
                "usage: http_length_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, 16> buffer{};
        std::string response;
        bool headers_done = false;
        std::size_t body_start = 0;
        std::size_t length = 0;
        int http_status = 0;
        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.starts_with("HTTP/1.0 ")) ||
                    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;
                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 == "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.
            }
        }
        std::cout << "status=" << http_status << " bytes=" << length << '\n';
        // Close this socket; surplus bytes are not a second response.
        std::cout.write(response.data() + body_start, length);
    } catch (const std::exception& error) {
        std::cerr << error.what() << '\n';
        status = 1;
    }
    if (fd >= 0) {
        ::close(fd);
    }
    return status;
}

The client ignores any surplus bytes already read beyond the selected body's length and closes the connection. It never interprets them as a second response. This is not a connection-reuse parser.

To compile without CMake:

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

No custom network header or additional library is needed.

Test the cases that EOF hides

Run this lesson's independent checks:

ctest --preset native-debug -R '^network[.]http_length[.]' --output-on-failure
Fixture Expected result
Complete body, peer still open Return the body before peer EOF.
Zero-length body, peer still open Finish after the headers.
EOF during headers Report truncated headers.
Length 5, EOF after only he Report truncated body, not a two-byte success.
Length over 4096 or integer overflow Reject from the headers without waiting for a body.
Duplicate length or transfer coding Reject this unsupported response profile.
Slowly arriving incomplete data The original receive deadline still expires.

The checks also cover a real Python server, mixed-case names, whitespace around values, binary bodies, exact size limits, invalid syntax and ignored surplus bytes. They do not claim general RFC conformance.

For comparison, the previous raw client waits for EOF even after a complete length-delimited response. These tests keep their peer sockets open until the parsed client has returned, so the change is observable rather than just a code comment.

Troubleshooting

  • Missing Content-Length: the message is outside this lesson's profile. A close-delimited response can be valid HTTP, but this client intentionally does not decode it.
  • Encoded response outside profile: do not ignore Transfer-Encoding and trust a length field anyway. Use the correct decoding path in a complete implementation.
  • Truncated body: the peer ended its sending direction before all declared bytes arrived. Do not silently lower the length.
  • Receive deadline: the response did not complete within the fixed budget. The optional third argument after port and path sets 1–10000 ms.
  • Unsupported status: body framing rules depend on request method and status. This client intentionally accepts only the two statuses used in the exercise.

Keep the server bound to loopback and serving only the fixture directory, as in the preceding lesson. Its 30-second timeout is separate from the client's receive budget.

Check your understanding

A header declares Content-Length: 5. The receiver has only he, and the next read returns zero. May it report a completed body because TCP ended cleanly?

Compare your reasoning

No. TCP EOF tells you no more bytes will arrive in that direction. The declared HTTP body is still three bytes short, so the response is incomplete.

Continue the course

Previous: Your First HTTP Request · Next: A Small HTTP Server · Course overview

References: HTTP/1.1 field parsing, message body length rules, Content-Length semantics.

Donate