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
155
implementations/Crafter.Network-ClientHTTP1.cpp
Normal file
155
implementations/Crafter.Network-ClientHTTP1.cpp
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//SPDX-License-Identifier: LGPL-3.0-only
|
||||
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module;
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <cerrno>
|
||||
|
||||
module Crafter.Network:ClientHTTP1_impl;
|
||||
import :ClientHTTP1;
|
||||
import :ClientTCP;
|
||||
import :HTTP;
|
||||
import :HTTP1;
|
||||
import Crafter.Thread;
|
||||
import std;
|
||||
|
||||
using namespace Crafter;
|
||||
|
||||
namespace {
|
||||
// Read whatever is available, waiting at most `timeout`. Returns 0 on a
|
||||
// clean close by the peer. ClientTCP::RecieveSync() can't express a
|
||||
// timeout — and a server that accepts the connection and then says
|
||||
// nothing would hang Send() forever — so the read is done here.
|
||||
std::size_t ReadSome(int socketid, char* buffer, std::size_t size,
|
||||
std::chrono::milliseconds timeout) {
|
||||
for (;;) {
|
||||
pollfd pfd{ .fd = socketid, .events = POLLIN, .revents = 0 };
|
||||
const int ready = poll(&pfd, 1, static_cast<int>(timeout.count()));
|
||||
if (ready < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("poll failed: ") + std::strerror(errno));
|
||||
}
|
||||
if (ready == 0) throw std::runtime_error("timed out waiting for the response");
|
||||
|
||||
const auto read = recv(socketid, buffer, size, 0);
|
||||
if (read < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
throw std::runtime_error(std::string("recv failed: ") + std::strerror(errno));
|
||||
}
|
||||
return static_cast<std::size_t>(read);
|
||||
}
|
||||
}
|
||||
|
||||
// Host header value. The port is elided when it is the scheme default,
|
||||
// which is what every other HTTP/1.1 client on the wire does and what
|
||||
// virtual-host matching on the far side tends to expect.
|
||||
std::string DefaultAuthority(const std::string& host, std::uint16_t port) {
|
||||
if (port == 80) return host;
|
||||
return host + ":" + std::to_string(port);
|
||||
}
|
||||
}
|
||||
|
||||
struct ClientHTTP1::Impl {
|
||||
std::string host;
|
||||
std::uint16_t port;
|
||||
std::unique_ptr<ClientTCP> tcp;
|
||||
std::chrono::milliseconds timeout{30000};
|
||||
|
||||
void Connect() {
|
||||
tcp = std::make_unique<ClientTCP>(host, port);
|
||||
}
|
||||
|
||||
void Close() {
|
||||
tcp.reset();
|
||||
}
|
||||
|
||||
// One request/response exchange on the current connection. `received`
|
||||
// is set as soon as any response byte arrives, which is what decides
|
||||
// whether a failure is safe to replay on a new connection.
|
||||
HTTPResponse Exchange(const std::string& wire, std::string_view method,
|
||||
const HTTP1::MessageLimits& limits, bool& received) {
|
||||
tcp->Send(wire.data(), static_cast<std::uint32_t>(wire.size()));
|
||||
|
||||
HTTP1::MessageParser parser(HTTP1::MessageKind::Response, limits);
|
||||
parser.SetRequestMethod(method);
|
||||
std::vector<char> chunk(16 * 1024);
|
||||
while (!parser.Complete()) {
|
||||
const std::size_t read = ReadSome(tcp->socketid, chunk.data(), chunk.size(), timeout);
|
||||
if (read == 0) {
|
||||
// Peer closed. Completes a response framed by close;
|
||||
// anything else throws out of Finish().
|
||||
parser.Finish();
|
||||
break;
|
||||
}
|
||||
received = true;
|
||||
parser.Feed(chunk.data(), read);
|
||||
}
|
||||
if (!parser.Complete()) {
|
||||
throw std::runtime_error("connection closed before a complete response arrived");
|
||||
}
|
||||
HTTPResponse response = parser.TakeResponse();
|
||||
if (!parser.KeepAlive()) Close();
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
ClientHTTP1::ClientHTTP1(const char* host, std::uint16_t port)
|
||||
: host(host), port(port), impl(std::make_unique<Impl>(std::string(host), port)) {}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(std::string host, std::uint16_t port)
|
||||
: ClientHTTP1(host.c_str(), port) {}
|
||||
|
||||
ClientHTTP1::ClientHTTP1(ClientHTTP1&&) noexcept = default;
|
||||
ClientHTTP1::~ClientHTTP1() = default;
|
||||
|
||||
bool ClientHTTP1::Connected() const noexcept {
|
||||
return impl && impl->tcp != nullptr;
|
||||
}
|
||||
|
||||
void ClientHTTP1::Disconnect() {
|
||||
if (impl) impl->Close();
|
||||
}
|
||||
|
||||
HTTPResponse ClientHTTP1::Send(const HTTPRequest& request) {
|
||||
HTTPRequest prepared = request;
|
||||
if (prepared.authority.empty()) prepared.authority = DefaultAuthority(host, port);
|
||||
const std::string wire = HTTP1::SerializeRequest(prepared);
|
||||
const std::string method = prepared.method.empty() ? std::string("GET") : prepared.method;
|
||||
|
||||
// Two attempts at most, and the second one only for the keep-alive
|
||||
// race: the peer may have closed a pooled connection at the same moment
|
||||
// we wrote to it, which is indistinguishable from success until the
|
||||
// read fails. Nothing is replayed once a response byte has arrived.
|
||||
for (int attempt = 0;; ++attempt) {
|
||||
const bool reused = impl->tcp != nullptr;
|
||||
if (!reused) impl->Connect();
|
||||
|
||||
bool received = false;
|
||||
try {
|
||||
return impl->Exchange(wire, method, limits, received);
|
||||
} catch (const std::exception&) {
|
||||
impl->Close();
|
||||
if (attempt == 0 && reused && !received) continue;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ClientHTTP1::SendAsync(const HTTPRequest& request,
|
||||
std::function<void(HTTPResponse)> onSuccess,
|
||||
std::function<void(std::string)> onError) {
|
||||
HTTPRequest copy = request;
|
||||
ThreadPool::Enqueue([this, copy = std::move(copy),
|
||||
onSuccess = std::move(onSuccess),
|
||||
onError = std::move(onError)]() mutable {
|
||||
try {
|
||||
HTTPResponse response = this->Send(copy);
|
||||
if (onSuccess) onSuccess(std::move(response));
|
||||
} catch (const std::exception& e) {
|
||||
if (onError) onError(e.what());
|
||||
} catch (...) {
|
||||
if (onError) onError("unknown error");
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue