feat(http1): bound the connect, and FetchHTTP1 for a URL with redirects

- ClientHTTP1 gains connectTimeout (default 15 s), applied to each
  address the host resolves to. `timeout` only ever bounded the waits for
  a response, so a host that drops the SYNs held Send() for the kernel's
  retries, about two minutes, whatever the caller had asked for.
- The default Host header puts an IPv6 literal in brackets.
- FetchHTTP1(url, options) makes one request to an http:// or https://
  URL and follows redirects as Python's urllib does, so urllib code ported
  over keeps its behaviour: 301/302/303/307/308 for GET and HEAD,
  301/302/303 for POST (re-sent as a GET without its body), a relative
  Location resolved as urljoin resolves it and percent-encoded as urllib
  quotes it, http and https hops mixed, each redirect's body read before
  the next request, and the chain stopped by urllib's limits (4 repeats
  of one URL, 10 different URLs) with the last redirect returned and
  `limited` set. Proxies are not consulted.

New: ShouldTimeoutSilentHTTP1, ShouldFollowRedirectsHTTP1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 05:06:41 +01:00
commit f9a45c143e
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
6 changed files with 649 additions and 6 deletions

View file

@ -0,0 +1,109 @@
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// ClientHTTP1 bounds the TCP connect (connectTimeout) as well as every wait
// for a response (timeout). Before, the connect had no bound of its own: a
// host that drops the SYNs held Send() for the kernel's retries, about two
// minutes, whatever `timeout` said — a readiness probe with a ten-second
// budget could hang its caller for two minutes.
//
// Two servers on loopback. One accepts — the kernel completes the handshake
// from its backlog — and never answers: the read timeout. The other has a
// full accept queue, so Linux drops the SYNs of further connects: the connect
// timeout.
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cstdlib>
import Crafter.Network;
import std;
using namespace Crafter;
using namespace std::chrono_literals;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// A socket listening on 127.0.0.1, on a port the kernel picks.
struct LoopbackListener {
int fd = -1;
std::uint16_t port = 0;
explicit LoopbackListener(int backlog) {
fd = socket(AF_INET, SOCK_STREAM, 0);
sockaddr_in address {};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
bind(fd, reinterpret_cast<sockaddr*>(&address), sizeof(address));
listen(fd, backlog);
socklen_t length = sizeof(address);
getsockname(fd, reinterpret_cast<sockaddr*>(&address), &length);
port = ntohs(address.sin_port);
}
~LoopbackListener() { close(fd); }
};
long long Milliseconds(std::chrono::steady_clock::duration duration) {
return std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
}
} // namespace
int main() {
{
LoopbackListener silent(8);
ClientHTTP1 client("127.0.0.1", silent.port);
client.timeout = 300ms;
const auto start = std::chrono::steady_clock::now();
bool failed = false;
try {
client.Send(CreateRequestHTTP("GET", "/", "127.0.0.1"));
} catch (const std::exception&) {
failed = true;
}
const auto waited = std::chrono::steady_clock::now() - start;
Check(failed, "a server that accepts and never answers fails the request");
Check(waited >= 280ms && waited < 5s, std::format("after about `timeout` ({} ms)", Milliseconds(waited)));
}
{
// Connects queue up until the accept queue is full, which with a
// backlog of 0 is after one or two; the next one goes unanswered.
LoopbackListener full(0);
std::vector<ClientTCP> queued;
bool queueFull = false;
for (int i = 0; i < 8 && !queueFull; ++i) {
try {
queued.emplace_back("127.0.0.1", full.port, 200ms);
} catch (const SocketTimeoutException&) {
queueFull = true;
}
}
Check(queueFull, std::format("the accept queue fills up (after {} connects)", queued.size()));
ClientHTTP1 client("127.0.0.1", full.port);
client.connectTimeout = 300ms;
const auto start = std::chrono::steady_clock::now();
bool timedOut = false;
try {
client.Send(CreateRequestHTTP("GET", "/", "127.0.0.1"));
} catch (const SocketTimeoutException&) {
timedOut = true;
} catch (const std::exception&) {
}
const auto waited = std::chrono::steady_clock::now() - start;
Check(timedOut, "a connect nobody answers throws SocketTimeoutException");
Check(waited >= 280ms && waited < 5s, std::format("after about `connectTimeout` ({} ms)", Milliseconds(waited)));
}
std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed");
return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}