A Small HTTP Server in C++ on Raspberry Pi
Your client already sends requests and reads length-delimited responses. Now switch sides: accept one request, choose a resource and construct the status line, headers and body yourself.
Before you start
Complete HTTP Headers and Content-Length. Use two terminals on the Pi in examples/network; the previous http_length_client is our first test client.
Outcome: turn a supported request into a correctly sized HTTP response, and explain why HEAD advertises a representation length but sends no body.
This is a one-request loopback fixture with a constant resource, not a public web server or a filesystem browser.
Decide what this small server supports
| Request |
Status |
Content-Length |
Sent body |
GET /hello.txt |
200 |
25 |
Hello from Raspberry Pi. plus newline. |
HEAD /hello.txt |
200 |
25 |
None. |
GET /missing |
404 |
10 |
Not found plus newline. |
HEAD /missing |
404 |
10 |
None. |
| Other valid method, no request body |
405 |
19 |
Method not allowed plus newline. |
The 405 response includes Allow: GET, HEAD. Method names are case-sensitive. Only the exact target /hello.txt is found; other supported origin paths return 404. There is no path-to-file mapping, percent decoding, query interpretation or disk access.
For HEAD, the advertised length describes what GET would send. It is not an instruction to wait for that many body bytes. This is one reason the previous client's GET-only response profile cannot be applied blindly to other methods.
sequenceDiagram
accTitle: GET sends the representation, HEAD sends only metadata
accDescr: In separate one-request runs, GET hello.txt receives status 200, Content-Length twenty-five and twenty-five body bytes. HEAD hello.txt receives the same status and length but no body. The server closes after either response.
participant C as Client
participant S as C++ server
alt GET in one run
C->>S: GET /hello.txt and Host
S->>C: 200 OK, Content-Length 25
S->>C: Empty line, then 25 body bytes
else HEAD in another run
C->>S: HEAD /hello.txt and Host
S->>C: 200 OK, Content-Length 25
Note over S,C: Empty line, no response body
end
S-->>C: Close this one-request connection
Build and make a real request
| cmake --preset native-debug
cmake --build --preset native-debug --parallel 2
|
Terminal A:
| ./build/native/debug/http_small_server 8081
|
It prints listening 127.0.0.1:8081. Within 30 seconds, run in terminal B:
| ./build/native/debug/http_length_client 8081
|
Client output:
| status=200 bytes=25
Hello from Raspberry Pi.
|
The server prints response=200 method=GET and exits. Restart it before each request, including a missing-resource experiment:
| ./build/native/debug/http_length_client 8081 /missing
|
That request produces a complete 404 response, not a connection error. If port 8081 is occupied, use another unprivileged port in both commands.
Inspect HEAD with an independent client
Restart the C++ server in terminal A. In terminal B:
| python3 - <<'PY'
import http.client
client = http.client.HTTPConnection("127.0.0.1", 8081, timeout=3)
try:
client.request("HEAD", "/hello.txt")
response = client.getresponse()
print("status=", response.status, sep="")
print("Content-Length=", response.getheader("Content-Length"), sep="")
print("body=", repr(response.read()), sep="")
finally:
client.close()
PY
|
Copy the whole block into the terminal. It runs Python directly without creating a file, using only the standard library.
Expected result:
| status=200
Content-Length=25
body=b''
|
The independent client knows it sent HEAD, so it does not apply GET's body-length rule. Restart the server again to try client.request("POST", "/hello.txt") with no body; Python sends a zero length and the server returns 405 with its Allow field.
Read the server in four passes
- Listen and accept. Bind only to IPv4 loopback. Accept one connection, then close the listener.
- Collect headers. Accumulate through
\r\n\r\n under a fixed deadline and a 4096-byte cap.
- Validate and select. Parse the method, target and version; validate headers and the local authority; choose 200, 404 or 405.
- Serialize and send. Calculate the representation size, build CRLF-delimited headers, omit the body for HEAD and loop over partial writes.
The resource is a constant C++ string. body.size() supplies the byte count, so changing the resource changes Content-Length automatically. The header terminator is not included in that count.
All socket calls and cleanup are in this one file. The lexical token lambda checks HTTP token characters; it is not a network wrapper.
Full standalone server: app/http-server/server.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 listener = -1;
int connection = -1;
int status = 0;
try {
if (argc < 2 || argc > 3) {
throw std::invalid_argument(
"usage: http_small_server port [request_ms]");
}
unsigned int port = 0;
const std::string_view port_text = argv[1];
const auto [end, error] = std::from_chars(
port_text.data(), port_text.data() + port_text.size(), port);
if (error != std::errc{} ||
end != port_text.data() + port_text.size() || port > 65535) {
throw std::invalid_argument("port must be 0..65535");
}
int request_ms = 3000;
if (argc == 3) {
const std::string_view text = argv[2];
const auto [last, invalid] = std::from_chars(
text.data(), text.data() + text.size(), request_ms);
if (invalid != std::errc{} || last != text.data() + text.size() ||
request_ms < 1 || request_ms > 10000) {
throw std::invalid_argument("request_ms must be 1..10000");
}
}
listener = ::socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0);
if (listener < 0) {
throw std::system_error(errno, std::generic_category(), "socket");
}
const int reuse = 1;
const timeval accept_timeout{30, 0};
if (::setsockopt(listener, SOL_SOCKET, SO_REUSEADDR, &reuse,
sizeof(reuse)) < 0 ||
::setsockopt(listener, SOL_SOCKET, SO_RCVTIMEO, &accept_timeout,
sizeof(accept_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt listener");
}
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(static_cast<std::uint16_t>(port));
local.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
if (::bind(listener, reinterpret_cast<const sockaddr*>(&local),
sizeof(local)) < 0 ||
::listen(listener, 1) < 0) {
throw std::system_error(errno, std::generic_category(),
"bind/listen");
}
socklen_t size = sizeof(local);
if (::getsockname(listener, reinterpret_cast<sockaddr*>(&local),
&size) < 0) {
throw std::system_error(errno, std::generic_category(),
"getsockname");
}
const std::string authority =
"127.0.0.1:" + std::to_string(ntohs(local.sin_port));
std::cout << "listening " << authority << std::endl;
connection = ::accept(listener, nullptr, nullptr);
if (connection < 0) {
throw std::system_error(errno, std::generic_category(), "accept");
}
::close(listener);
listener = -1;
const timeval send_timeout{3, 0};
if (::setsockopt(connection, SOL_SOCKET, SO_SNDTIMEO, &send_timeout,
sizeof(send_timeout)) < 0) {
throw std::system_error(errno, std::generic_category(),
"setsockopt send");
}
using Clock = std::chrono::steady_clock;
const auto deadline =
Clock::now() + std::chrono::milliseconds(request_ms);
std::string request;
std::array<char, 128> buffer{};
std::size_t boundary = std::string::npos;
while ((boundary = request.find("\r\n\r\n")) == std::string::npos) {
const auto remaining = std::chrono::ceil<std::chrono::milliseconds>(
deadline - Clock::now())
.count();
if (remaining <= 0) {
throw std::runtime_error("request deadline");
}
pollfd ready{connection, 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(connection, buffer.data(), buffer.size(), MSG_DONTWAIT);
if (received < 0) {
if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) {
continue;
}
throw std::system_error(errno, std::generic_category(), "recv");
}
if (received == 0) {
throw std::runtime_error("truncated request");
}
request.append(buffer.data(), static_cast<std::size_t>(received));
if (request.size() > 4096) {
throw std::runtime_error("request exceeds 4096 bytes");
}
}
if (request.size() != boundary + 4) {
throw std::runtime_error(
"buffered request body or pipeline unsupported");
}
const auto first_end = request.find("\r\n");
const std::string_view line(request.data(), first_end);
const auto first_space = line.find(' ');
const auto second_space = first_space == std::string_view::npos
? first_space
: line.find(' ', first_space + 1);
if (first_space == std::string_view::npos ||
second_space == std::string_view::npos ||
line.substr(second_space + 1) != "HTTP/1.1") {
throw std::runtime_error("invalid request line");
}
const auto method = line.substr(0, first_space);
const auto target =
line.substr(first_space + 1, second_space - first_space - 1);
const std::string_view punctuation = "!#$%&'*+-.^_\x60|~";
const auto token = [punctuation](unsigned char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
punctuation.find(c) != std::string_view::npos;
};
if (method.empty() ||
!std::all_of(method.begin(), method.end(), token) ||
target.empty() || target.front() != '/' || target.size() > 200 ||
!std::all_of(target.begin(), target.end(), [](unsigned char c) {
return c > 32 && c < 127 && c != '#';
})) {
throw std::runtime_error("invalid method or target");
}
bool host_seen = false;
bool length_seen = false;
std::size_t cursor = first_end + 2;
while (cursor < boundary) {
const auto line_end = request.find("\r\n", cursor);
const std::string_view field(request.data() + cursor,
line_end - cursor);
const auto colon = field.find(':');
if (colon == std::string_view::npos || colon == 0) {
throw std::runtime_error("invalid header line");
}
std::string name(field.substr(0, colon));
if (!std::all_of(name.begin(), name.end(), token)) {
throw std::runtime_error("invalid header name");
}
for (auto& c : name) {
if (c >= 'A' && c <= 'Z') {
c += 'a' - 'A';
}
}
auto value = field.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 == "host") {
if (host_seen || value != authority) {
throw std::runtime_error("duplicate or unsupported Host");
}
host_seen = true;
}
if (name == "transfer-encoding" || name == "expect") {
throw std::runtime_error("request feature unsupported");
}
if (name == "content-length") {
std::size_t length = 0;
const auto [last, invalid] = std::from_chars(
value.data(), value.data() + value.size(), length);
if (length_seen || invalid != std::errc{} ||
last != value.data() + value.size() || length != 0) {
throw std::runtime_error(
"request body or duplicate length unsupported");
}
length_seen = true;
}
cursor = line_end + 2;
}
if (!host_seen) {
throw std::runtime_error("missing Host");
}
const bool allowed = method == "GET" || method == "HEAD";
const bool found = target == "/hello.txt";
const std::string_view code = !allowed ? "405 Method Not Allowed"
: found ? "200 OK"
: "404 Not Found";
const std::string_view body = !allowed ? "Method not allowed\n"
: found ? "Hello from Raspberry Pi.\n"
: "Not found\n";
std::string response =
"HTTP/1.1 " + std::string(code) +
"\r\nContent-Type: text/plain\r\nContent-Length: " +
std::to_string(body.size()) + "\r\nConnection: close\r\n";
if (!allowed) {
response += "Allow: GET, HEAD\r\n";
}
response += "\r\n";
if (method != "HEAD") {
response += body;
}
std::size_t offset = 0;
while (offset < response.size()) {
const auto sent = ::send(connection, response.data() + offset,
response.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);
}
std::cout << "response=" << code.substr(0, 3) << " method=" << method
<< '\n';
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
status = 1;
}
if (connection >= 0) {
::close(connection);
}
if (listener >= 0) {
::close(listener);
}
return status;
}
|
Compile directly if desired:
| mkdir -p build/direct
c++ -std=c++20 -Wall -Wextra -Wpedantic \
app/http-server/server.cpp -o build/direct/http_small_server
|
Run that executable with the same port argument and use the already built client.
Keep the request profile explicit
The example accepts HTTP/1.1 requests with one Host equal to 127.0.0.1:port, a visible ASCII origin path of at most 200 bytes, ASCII field syntax and no request body. An absent length or one valid zero Content-Length is supported.
It rejects duplicate Host or length fields, nonzero request lengths, Transfer-Encoding, Expect, malformed fields and other HTTP versions. It also rejects extra request bytes already buffered after the header terminator. It does not attempt to detect later pipelined bytes or process another request.
A teaching fixture, not a conformant general server
Rejected input closes the connection and reports an error to the server terminal; this small fixture does not generate a full set of HTTP error responses. It also omits production features such as Date generation, host aliases, request bodies and graceful draining of rejected input. Clients may see EOF or reset on rejection.
Keep it on loopback. Do not turn it into a public listener, proxy or file server by changing only the bind address. The 200/404/405 responses above belong to the supported request profile.
The listener waits about 30 seconds. Header collection has a fixed three-second budget, optionally changed with the second argument, in milliseconds from 1 to 10000:
| ./build/native/debug/http_small_server 8081 1000
|
That budget is for request-header collection, not the whole exchange. Response sends have separate three-second Linux per-call timeouts. The process handles one accepted connection and exits, so no service is installed.
Verify more than a browser page
| ctest --preset native-debug -R '^network[.]http_server[.]' --output-on-failure
|
The checks use the previous C++ client, Python's HTTP client and raw byte fixtures. They verify GET, HEAD, 404, 405, split input, mixed-case headers, invalid authorities, unsupported bodies/coding, truncation, deadlines and port errors.
A browser may open extra connections or request a favicon. This one-request fixture is intentionally easier to observe using the supplied clients; it is not a browser-serving benchmark.
Check your understanding
You change the resource from 25 to 40 bytes. What should a successful HEAD response send?
Compare your reasoning
The headers should advertise Content-Length 40, matching the selected GET representation, but HEAD must still send no response body. Computing length from the same resource avoids maintaining two unrelated constants.
Continue the course
Previous: HTTP Headers and Body Length · Next: Chunked HTTP Bodies · Course overview
References: HEAD semantics, 405 and Allow, HTTP/1.1 message syntax.