feat(http1): add an HTTP/1.1 client and listener
HTTP/3-only is not a deployable position yet: plenty of clients, proxies and CI tooling still speak nothing but HTTP/1.1. This adds that path using the request/response types the HTTP/3 stack already uses, so a route handler or call site moves between the two protocols by changing the class name. - :HTTP1 — transport-free wire format. Serialisation with the framing headers owned by the serialiser, and an incremental parser that takes arbitrary socket chunks and yields one message at a time: keep-alive, pipelining, content-length and chunked bodies (with trailers), read-to-EOF responses, interim 1xx skipping, HEAD/204/304 framing and Expect: 100-continue. Ambiguous framing is rejected rather than guessed at (content-length with transfer-encoding, disagreeing content-lengths, whitespace before a colon), and CR/LF in a value we are asked to serialise is refused. - ClientHTTP1 — persistent connection, redialling once when a pooled connection turns out to have been closed by the peer, which is the race HTTP/1.1 keep-alive cannot avoid. Nothing is replayed after a response byte has arrived. - ListenerHTTP1 — one thread per connection (keep-alive connections are idle most of their life and would pin every ThreadPool thread), automatic Date, HEAD, 100-continue, handler-requested close, idle and request timeouts, and 400/404/500 responses. Routes fall back to the query-stripped path so `/thing?x=1` reaches the handler for `/thing`. No TLS: this is `http://` only. Encrypted traffic still goes over HTTP/3, or through a TLS-terminating proxy. Tests: codec unit tests including the malformed inputs above, a client/server round-trip, keep-alive and stale-connection recovery, a 10 MiB body both ways, and interop both directions against curl and python3's http.server (skipped when those are not installed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b758419007
commit
337ce32eca
12 changed files with 2175 additions and 4 deletions
89
tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp
Normal file
89
tests/ShouldSendRecieveKeepaliveHTTP1/main.cpp
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// HTTP/1.1 connection reuse. Unlike HTTP/3 — where multiplexing is the
|
||||
// transport's job — keep-alive here means the same socket carries request
|
||||
// after request, and both ends have to agree on where each message ends.
|
||||
// This test pins that down: repeated requests must not open new
|
||||
// connections, `Connection: close` must end the connection, and the client
|
||||
// must recover when the server closes a pooled connection under it.
|
||||
|
||||
import Crafter.Network;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
int failures = 0;
|
||||
|
||||
void Check(bool condition, std::string_view what) {
|
||||
if (!condition) {
|
||||
std::println("FAIL: {}", what);
|
||||
++failures;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
ThreadPool::Start();
|
||||
|
||||
std::unordered_map<std::string, std::function<HTTPResponse(const HTTPRequest&)>> routes;
|
||||
routes["/"] = [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "Hello World!");
|
||||
};
|
||||
routes["/once"] = [](const HTTPRequest&) {
|
||||
// A handler that asks for the connection to end after this reply.
|
||||
return CreateResponseHTTP("200", {{"connection", "close"}}, "bye");
|
||||
};
|
||||
|
||||
try {
|
||||
ListenerAsyncHTTP1 listener(8091, std::move(routes));
|
||||
ClientHTTP1 client("localhost", 8091);
|
||||
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
HTTPResponse response = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(response.status == "200", "keep-alive request status");
|
||||
Check(response.body == "Hello World!", "keep-alive request body");
|
||||
Check(client.Connected(), "the client keeps the socket between requests");
|
||||
}
|
||||
Check(listener.listener.AcceptedCount() == 1, "five requests shared one connection");
|
||||
|
||||
// A handler that answers with `connection: close` ends the
|
||||
// connection after its response; the client must notice and dial
|
||||
// again for the next request instead of writing into a dead socket.
|
||||
HTTPResponse closing = client.Send(CreateRequestHTTP("GET", "/once", "localhost"));
|
||||
Check(closing.status == "200", "close-flagged response still arrives");
|
||||
Check(closing.body == "bye", "close-flagged response body");
|
||||
Check(!client.Connected(), "the client drops a connection the server closed");
|
||||
|
||||
HTTPResponse after = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(after.body == "Hello World!", "the next request redials transparently");
|
||||
Check(listener.listener.AcceptedCount() == 2, "exactly one extra connection was opened");
|
||||
|
||||
// Force the stale-connection race that HTTP/1.1 keep-alive cannot
|
||||
// avoid: the server goes away while a pooled connection looks
|
||||
// usable. The client is expected to replay the request on a fresh
|
||||
// connection rather than surface the error.
|
||||
Check(client.Connected(), "a connection is pooled before the restart");
|
||||
listener.Stop();
|
||||
ListenerAsyncHTTP1 restarted(8091, {{"/", [](const HTTPRequest&) {
|
||||
return CreateResponseHTTP("200", "Hello Again!");
|
||||
}}});
|
||||
HTTPResponse recovered = client.Send(CreateRequestHTTP("GET", "/", "localhost"));
|
||||
Check(recovered.body == "Hello Again!", "the client recovers from a stale pooled connection");
|
||||
Check(restarted.listener.AcceptedCount() == 1, "recovery dialled the new listener");
|
||||
|
||||
restarted.Stop();
|
||||
} catch (const std::exception& error) {
|
||||
std::println("threw: {}", error.what());
|
||||
ThreadPool::Stop();
|
||||
return 1;
|
||||
}
|
||||
|
||||
ThreadPool::Stop();
|
||||
if (failures != 0) {
|
||||
std::println("{} check(s) failed", failures);
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Loading…
Reference in a new issue