Crafter.Network/implementations/Crafter.Network-ClientHTTP1.cpp

155 lines
5.7 KiB
C++
Raw Normal View History

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>
2026-07-27 00:45:09 +00:00
//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;
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,
std::chrono::milliseconds timeout, bool& received) {
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>
2026-07-27 00:45:09 +00:00
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, timeout, received);
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>
2026-07-27 00:45:09 +00:00
} 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");
}
});
}