From 8c5406f560f2bbfd299502689cf046e612cc5db1 Mon Sep 17 00:00:00 2001 From: kekxv Date: Fri, 9 Oct 2026 22:45:24 +0800 Subject: [PATCH 1/3] feat(http-client): add phase-specific timeouts --- MODULE.bazel | 2 +- doc/http-client.md | 6 ++ example/MODULE.bazel | 2 +- framework/client/client_timeouts.hpp | 35 ++++++++++ framework/client/http_client.cpp | 91 +++++++++++++++++-------- framework/client/http_client.hpp | 4 +- framework/client/http_client_stream.cpp | 5 ++ framework/client/http_client_stream.hpp | 2 + framework/tests/client_test.cpp | 67 +++++++++++++++++- 9 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 framework/client/client_timeouts.hpp diff --git a/MODULE.bazel b/MODULE.bazel index af3218c..18f21cc 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -1,6 +1,6 @@ module( name = "khttpd", - version = "0.5.1", + version = "0.5.2", ) bazel_dep(name = "platforms", version = "1.1.0") diff --git a/doc/http-client.md b/doc/http-client.md index 586c244..a728ce2 100644 --- a/doc/http-client.md +++ b/doc/http-client.md @@ -311,3 +311,9 @@ private: ws->set_header("Authorization", "Bearer token123"); ws->connect("wss://api.example.com/ws", ...); ``` + +### HTTP request deadlines + +`set_timeouts(ClientTimeouts{total, connect, read, write})` configures independent whole-request, DNS/TCP/TLS connection, response-read, and request-write deadlines. Values use `std::chrono::seconds`; zero disables the corresponding deadline. The earliest applicable deadline cancels the request with category `http.client.timeout` (1 total, 2 connect, 3 read, 4 write). The total deadline does not reset when data is transferred. `set_timeout(seconds)` applies the same limit to all four fields, including zero as unlimited. Stream reads and writes apply their corresponding phase limit to each operation. + +On-wire regression coverage is in `framework/tests/client_test.cpp`: delayed response with read/total limits, zero read/total, stalled TLS handshake, and a stalled upload. Gateway and provider-specific tests cover their streaming and proxy policies separately. diff --git a/example/MODULE.bazel b/example/MODULE.bazel index a8ff517..0800d77 100644 --- a/example/MODULE.bazel +++ b/example/MODULE.bazel @@ -8,7 +8,7 @@ bazel_dep(name = "boost", version = "1.90.0.bcr.1") bazel_dep(name = "boost.asio", version = "1.90.0.bcr.1") bazel_dep(name = "boost.mysql", version = "1.90.0.bcr.1") bazel_dep(name = "spdlog", version = "1.17.0") -bazel_dep(name = "khttpd", version = "0.5.1") +bazel_dep(name = "khttpd", version = "0.5.2") local_path_override( module_name = "khttpd", path = "..", diff --git a/framework/client/client_timeouts.hpp b/framework/client/client_timeouts.hpp new file mode 100644 index 0000000..cdd9ce6 --- /dev/null +++ b/framework/client/client_timeouts.hpp @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include +#include +namespace khttpd::framework::client { +struct ClientTimeouts { + std::chrono::seconds total{30}, connect{30}, read{30}, write{30}; +}; +inline void validate_client_timeouts(const ClientTimeouts& value) { + for (auto duration : {value.total, value.connect, value.read, value.write}) + if (duration.count() < 0 || duration.count() > INT_MAX) + throw std::invalid_argument("client timeout must be between 0 and INT_MAX seconds"); +} +class ClientTimeoutCategory final : public boost::system::error_category { + public: + const char* name() const noexcept override { return "http.client.timeout"; } + std::string message(int phase) const override { + switch(phase) { + case 1: return "HTTP client total timeout"; + case 2: return "HTTP client connect timeout"; + case 3: return "HTTP client read timeout"; + case 4: return "HTTP client write timeout"; + default: return "HTTP client timeout"; + } + } + boost::system::error_condition default_error_condition(int) const noexcept override { + return boost::system::errc::make_error_condition(boost::system::errc::timed_out); + } +}; +inline boost::system::error_code client_timeout_error(int phase) { + static ClientTimeoutCategory category; + return {phase,category}; +} +} diff --git a/framework/client/http_client.cpp b/framework/client/http_client.cpp index c69c9fb..7dcc851 100644 --- a/framework/client/http_client.cpp +++ b/framework/client/http_client.cpp @@ -29,22 +29,44 @@ class Session : public std::enable_shared_from_this { http::request req_; std::optional> response_parser_; beast::flat_buffer buffer_; - std::chrono::seconds timeout_; + ClientTimeouts timeouts_; + net::strand executor_; + net::steady_timer total_timer_, phase_timer_; std::atomic completed_{false}; public: - Session(HttpClient::ResponseCallback callback, std::chrono::seconds timeout) - : callback_(std::move(callback)), timeout_(timeout) {} + Session(net::io_context& io, HttpClient::ResponseCallback callback, ClientTimeouts timeouts) + : callback_(std::move(callback)), timeouts_(timeouts), executor_(net::make_strand(io)), + total_timer_(executor_), phase_timer_(executor_) {} virtual ~Session() = default; - virtual void run(const std::string& host, const std::string& port, - http::request req) = 0; + void run(const std::string& host, const std::string& port, http::request req) { + net::post(executor_, [self=shared_from_this(),host,port,req=std::move(req)]() mutable { + self->arm(self->total_timer_,self->timeouts_.total,1); + self->phase(self->timeouts_.connect,2); + self->start(host,port,std::move(req)); + }); + } + virtual void start(const std::string& host, const std::string& port, + http::request req) = 0; virtual void cancel() = 0; protected: + void arm(net::steady_timer& timer, std::chrono::seconds duration, int code) { + timer.cancel(); + if (completed_.load() || duration.count()==0) return; + timer.expires_after(duration); + timer.async_wait([self=shared_from_this(),code](beast::error_code error) { + if (error || self->completed_.load()) return; + self->complete(client_timeout_error(code),{}); + self->cancel(); + }); + } + void phase(std::chrono::seconds duration, int code) { arm(phase_timer_,duration,code); } void complete(beast::error_code ec, http::response res) { - if (!completed_.exchange(true) && callback_) { - callback_(ec, std::move(res)); + if (!completed_.exchange(true)) { + total_timer_.cancel(); phase_timer_.cancel(); + if (callback_) callback_(ec, std::move(res)); } } @@ -69,15 +91,14 @@ class HttpSession : public Session { public: HttpSession(net::io_context& ioc, HttpClient::ResponseCallback cb, - std::chrono::seconds timeout) - : Session(std::move(cb), timeout), - stream_(net::make_strand(ioc)), + ClientTimeouts timeouts) + : Session(ioc, std::move(cb), timeouts), + stream_(executor_), resolver_(stream_.get_executor()) {} - void run(const std::string& host, const std::string& port, + void start(const std::string& host, const std::string& port, http::request req) override { req_ = std::move(req); - stream_.expires_after(timeout_); resolver_.async_resolve( host, port, beast::bind_front_handler(&HttpSession::on_resolve, get_shared())); @@ -94,16 +115,17 @@ class HttpSession : public Session { } void on_resolve(beast::error_code ec, tcp::resolver::results_type results) { + if (completed_.load()) return; if (ec) return on_fail(ec, "resolve"); - stream_.expires_after(timeout_); stream_.async_connect(results, beast::bind_front_handler( &HttpSession::on_connect, get_shared())); } void on_connect(beast::error_code ec, tcp::resolver::results_type::endpoint_type) { + if (completed_.load()) return; if (ec) return on_fail(ec, "connect"); - stream_.expires_after(timeout_); + phase(timeouts_.write,4); http::async_write( stream_, req_, beast::bind_front_handler(&HttpSession::on_write, get_shared())); @@ -111,12 +133,15 @@ class HttpSession : public Session { void on_write(beast::error_code ec, std::size_t bytes_transferred) { boost::ignore_unused(bytes_transferred); + if (completed_.load()) return; if (ec) return on_fail(ec, "write"); + phase(timeouts_.read,3); read_response(); } void read_response() { + if (completed_.load()) return; response_parser_.emplace(); response_parser_->body_limit((std::numeric_limits::max)()); response_parser_->skip(req_.method() == http::verb::head); @@ -152,12 +177,12 @@ class HttpsSession : public Session { public: HttpsSession(net::io_context& ioc, ssl::context& ctx, - HttpClient::ResponseCallback cb, std::chrono::seconds timeout) - : Session(std::move(cb), timeout), - stream_(net::make_strand(ioc), ctx), + HttpClient::ResponseCallback cb, ClientTimeouts timeouts) + : Session(ioc, std::move(cb), timeouts), + stream_(executor_, ctx), resolver_(stream_.get_executor()) {} - void run(const std::string& host, const std::string& port, + void start(const std::string& host, const std::string& port, http::request req) override { req_ = std::move(req); if (!SSL_set_tlsext_host_name(stream_.native_handle(), host.c_str())) { @@ -167,7 +192,6 @@ class HttpsSession : public Session { } stream_.set_verify_callback(ssl::host_name_verification(host)); - stream_.next_layer().expires_after(timeout_); resolver_.async_resolve( host, port, beast::bind_front_handler(&HttpsSession::on_resolve, get_shared())); @@ -186,8 +210,8 @@ class HttpsSession : public Session { } void on_resolve(beast::error_code ec, tcp::resolver::results_type results) { + if (completed_.load()) return; if (ec) return on_fail(ec, "resolve"); - stream_.next_layer().expires_after(timeout_); beast::get_lowest_layer(stream_).async_connect( results, beast::bind_front_handler(&HttpsSession::on_connect, get_shared())); @@ -195,16 +219,17 @@ class HttpsSession : public Session { void on_connect(beast::error_code ec, tcp::resolver::results_type::endpoint_type) { + if (completed_.load()) return; if (ec) return on_fail(ec, "connect"); - stream_.next_layer().expires_after(timeout_); stream_.async_handshake( ssl::stream_base::client, beast::bind_front_handler(&HttpsSession::on_handshake, get_shared())); } void on_handshake(beast::error_code ec) { + if (completed_.load()) return; if (ec) return on_fail(ec, "handshake"); - stream_.next_layer().expires_after(timeout_); + phase(timeouts_.write,4); http::async_write( stream_, req_, beast::bind_front_handler(&HttpsSession::on_write, get_shared())); @@ -212,11 +237,14 @@ class HttpsSession : public Session { void on_write(beast::error_code ec, std::size_t bytes_transferred) { boost::ignore_unused(bytes_transferred); + if (completed_.load()) return; if (ec) return on_fail(ec, "write"); + phase(timeouts_.read,3); read_response(); } void read_response() { + if (completed_.load()) return; response_parser_.emplace(); response_parser_->body_limit((std::numeric_limits::max)()); response_parser_->skip(req_.method() == http::verb::head); @@ -320,7 +348,11 @@ void HttpClient::set_bearer_token(const std::string& token) { } void HttpClient::set_timeout(std::chrono::seconds seconds) { - timeout_ = seconds; + set_timeouts({seconds,seconds,seconds,seconds}); +} +void HttpClient::set_timeouts(ClientTimeouts timeouts) { + validate_client_timeouts(timeouts); + timeouts_ = timeouts; } HttpClient::UrlParts HttpClient::parse_target( @@ -421,10 +453,10 @@ void HttpClient::request(http::verb method, std::string path, return; } session = std::make_shared(ioc_, *ssl_ctx_ptr_, - std::move(callback), timeout_); + std::move(callback), timeouts_); } else { session = - std::make_shared(ioc_, std::move(callback), timeout_); + std::make_shared(ioc_, std::move(callback), timeouts_); } session->run(parts.host, parts.port, std::move(req)); } catch (const std::exception& e) { @@ -474,10 +506,10 @@ http::response HttpClient::request_sync( beast::errc::operation_not_supported, beast::system_category())); } session = std::make_shared(ioc_, *ssl_ctx_ptr_, - std::move(callback), timeout_); + std::move(callback), timeouts_); } else { session = - std::make_shared(ioc_, std::move(callback), timeout_); + std::make_shared(ioc_, std::move(callback), timeouts_); } session->run(parts.host, parts.port, std::move(req)); } catch (const boost::system::system_error&) { @@ -487,12 +519,13 @@ http::response HttpClient::request_sync( beast::errc::invalid_argument, beast::system_category())); } - if (f.wait_for(timeout_ + std::chrono::seconds(1)) != + if (timeouts_.total.count() > 0 && + f.wait_for(timeouts_.total + std::chrono::seconds(1)) != std::future_status::ready) { completed->store(true); if (session) session->cancel(); throw boost::system::system_error( - beast::error_code(beast::errc::timed_out, beast::system_category())); + client_timeout_error(1)); } auto result = f.get(); diff --git a/framework/client/http_client.hpp b/framework/client/http_client.hpp index bf6ce29..cdd5b41 100644 --- a/framework/client/http_client.hpp +++ b/framework/client/http_client.hpp @@ -11,6 +11,7 @@ #include #include #include +#include "client_timeouts.hpp" #include #include #include @@ -92,6 +93,7 @@ class HttpClient : public std::enable_shared_from_this { void set_default_header(const std::string& key, const std::string& value); void set_bearer_token(const std::string& token); void set_timeout(std::chrono::seconds seconds); + void set_timeouts(ClientTimeouts timeouts); // Core Request Method (Used by Macros) void request(http::verb method, @@ -130,7 +132,7 @@ class HttpClient : public std::enable_shared_from_this { std::unique_ptr host_pool_; // Multi-host support (null if single host) std::map default_headers_; - std::chrono::seconds timeout_{30}; + ClientTimeouts timeouts_; }; } // namespace khttpd::framework::client diff --git a/framework/client/http_client_stream.cpp b/framework/client/http_client_stream.cpp index 01454f1..32dbbdd 100644 --- a/framework/client/http_client_stream.cpp +++ b/framework/client/http_client_stream.cpp @@ -44,6 +44,7 @@ struct HttpClientStream::Impl http::verb request_method = http::verb::unknown; std::string host; std::string port; + std::function start_write_callback; bool use_tls = false; bool started = false; bool request_finished = false; @@ -137,6 +138,7 @@ struct HttpClientStream::Impl template void async_write_header(Stream& stream, Callback callback) { + if (start_write_callback) start_write_callback(); http::async_write_header( stream, *request_serializer, [self = shared_from_this(), callback = std::move(callback)]( @@ -321,4 +323,7 @@ void HttpClientStream::async_read_some(net::mutable_buffer buffer, impl_->read(buffer, std::move(cb)); } void HttpClientStream::cancel() { impl_->cancel(); } +void HttpClientStream::set_start_write_callback(std::function callback) { + impl_->start_write_callback=std::move(callback); +} } // namespace khttpd::framework::client diff --git a/framework/client/http_client_stream.hpp b/framework/client/http_client_stream.hpp index 803d8d3..dfa57a9 100644 --- a/framework/client/http_client_stream.hpp +++ b/framework/client/http_client_stream.hpp @@ -34,6 +34,8 @@ class HttpClientStream : public std::enable_shared_from_this { void async_read_some(boost::asio::mutable_buffer buffer, ReadCallback callback); void cancel(); + // Set before async_start; called on the stream executor before sending the head. + void set_start_write_callback(std::function callback); private: struct Impl; diff --git a/framework/tests/client_test.cpp b/framework/tests/client_test.cpp index 20f6a4f..83ca402 100644 --- a/framework/tests/client_test.cpp +++ b/framework/tests/client_test.cpp @@ -477,14 +477,15 @@ TEST(HttpClientLocalTest, SyncRequestWithUnrunExternalIoContextTimesOut) { boost::asio::io_context ioc; HttpClient client(ioc); client.set_base_url("http://127.0.0.1:9"); - client.set_timeout(std::chrono::seconds(0)); + // Zero now means unlimited; use a positive bound for an unrun executor. + client.set_timeout(std::chrono::seconds(1)); auto start = std::chrono::steady_clock::now(); EXPECT_THROW(client.request_sync(http::verb::get, "/", {}, "", {}), boost::system::system_error); auto elapsed = std::chrono::steady_clock::now() - start; - EXPECT_LT(elapsed, std::chrono::seconds(2)); + EXPECT_LT(elapsed, std::chrono::seconds(3)); } TEST(HttpClientLocalTest, SyncRequestTimeoutClosesStalledConnection) { @@ -953,3 +954,65 @@ TEST_F(ClientTest, MultiHostClientPool) { WAIT_FOR_ASYNC(future); } + +// Local on-wire regressions for independent phase and unlimited deadlines. +TEST(ClientTimeout, LegacyZeroDoesNotImmediatelyCancelSlowResponse) { + net::io_context io; + tcp::acceptor acceptor(io, {net::ip::make_address("127.0.0.1"), 0}); + auto server = std::async(std::launch::async, [&] { + tcp::socket socket(io); acceptor.accept(socket); + boost::beast::flat_buffer buffer; http::request request; + boost::system::error_code error; + http::read(socket, buffer, request, error); + if (error) return; + EXPECT_EQ(std::string(request.target()),"/status?channel=1"); + std::this_thread::sleep_for(std::chrono::milliseconds(1200)); + http::response response{http::status::ok,11}; + response.body()="ready"; response.prepare_payload(); + http::write(socket, response, error); + }); + khttpd::framework::client::HttpClient client; + client.set_base_url("http://127.0.0.1:" + std::to_string(acceptor.local_endpoint().port())); + client.set_timeout(std::chrono::seconds(0)); + EXPECT_NO_THROW(EXPECT_EQ(client.request_sync(http::verb::get,"/status?channel=1",{},"",{}).body(),"ready")); + server.get(); +} + +static void slow_response(const ClientTimeouts& options,const char* phase) { + net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); + auto server=std::async(std::launch::async,[&] { + tcp::socket socket(io); acceptor.accept(socket); + boost::beast::flat_buffer buffer; http::request request; + boost::system::error_code error; http::read(socket,buffer,request,error); if(error) return; + std::this_thread::sleep_for(std::chrono::milliseconds(1300)); + http::response response{http::status::ok,11}; response.body()="ready"; response.prepare_payload(); http::write(socket,response,error); + }); + khttpd::framework::client::HttpClient client; + client.set_base_url("http://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); client.set_timeouts(options); + if(!phase) EXPECT_EQ(client.request_sync(http::verb::get,"/",{},"",{}).body(),"ready"); + else { try { client.request_sync(http::verb::get,"/",{},"",{}); FAIL()<<"timeout expected"; } + catch(const boost::system::system_error& error) { EXPECT_NE(std::string(error.what()).find(phase),std::string::npos); } } + server.get(); +} +TEST(ClientTimeout, ReadDeadlineNamesPhase) { slow_response({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(1),std::chrono::seconds(2)},"read"); } +TEST(ClientTimeout, TotalDeadlineAppliesWithUnlimitedRead) { slow_response({std::chrono::seconds(1),std::chrono::seconds(2),std::chrono::seconds(0),std::chrono::seconds(2)},"total"); } +TEST(ClientTimeout, UnlimitedTotalStillHonorsReadDeadline) { slow_response({std::chrono::seconds(0),std::chrono::seconds(2),std::chrono::seconds(1),std::chrono::seconds(2)},"read"); } +TEST(ClientTimeout, ZeroReadWaitsWhenTotalAllowsIt) { slow_response({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(0),std::chrono::seconds(2)},nullptr); } +TEST(ClientTimeout, TlsHandshakeCoveredByConnectDeadline) { + net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); + auto server=std::async(std::launch::async,[&] { tcp::socket socket(io); acceptor.accept(socket); std::this_thread::sleep_for(std::chrono::milliseconds(1300)); }); + khttpd::framework::client::HttpClient client; + client.set_base_url("https://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); + client.set_timeouts({std::chrono::seconds(3),std::chrono::seconds(1),std::chrono::seconds(2),std::chrono::seconds(2)}); + try {client.request_sync(http::verb::get,"/",{},"",{}); FAIL()<<"timeout expected";} + catch(const boost::system::system_error& error) {EXPECT_NE(std::string(error.what()).find("connect"),std::string::npos);} server.get(); +} +TEST(ClientTimeout, StalledUploadCoveredByWriteDeadline) { + net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); + auto server=std::async(std::launch::async,[&] {tcp::socket socket(io); acceptor.accept(socket); socket.set_option(net::socket_base::receive_buffer_size(1024)); std::this_thread::sleep_for(std::chrono::milliseconds(1500)); }); + khttpd::framework::client::HttpClient client; + client.set_base_url("http://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); + client.set_timeouts({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(2),std::chrono::seconds(1)}); + try {client.request_sync(http::verb::post,"/",{},std::string(16*1024*1024,'x'),{}); FAIL()<<"timeout expected";} + catch(const boost::system::system_error& error) {EXPECT_NE(std::string(error.what()).find("write"),std::string::npos);} server.get(); +} From d45b41f26c911f2cf6c958e14e138861eb26377d Mon Sep 17 00:00:00 2001 From: kekxv Date: Fri, 9 Oct 2026 22:57:42 +0800 Subject: [PATCH 2/3] test(http-client): make timeout assertions portable --- framework/tests/client_test.cpp | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/framework/tests/client_test.cpp b/framework/tests/client_test.cpp index 83ca402..eb41dc8 100644 --- a/framework/tests/client_test.cpp +++ b/framework/tests/client_test.cpp @@ -978,7 +978,7 @@ TEST(ClientTimeout, LegacyZeroDoesNotImmediatelyCancelSlowResponse) { server.get(); } -static void slow_response(const ClientTimeouts& options,const char* phase) { +static void slow_response(const ClientTimeouts& options, int phase) { net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); auto server=std::async(std::launch::async,[&] { tcp::socket socket(io); acceptor.accept(socket); @@ -989,15 +989,18 @@ static void slow_response(const ClientTimeouts& options,const char* phase) { }); khttpd::framework::client::HttpClient client; client.set_base_url("http://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); client.set_timeouts(options); - if(!phase) EXPECT_EQ(client.request_sync(http::verb::get,"/",{},"",{}).body(),"ready"); + if(phase == 0) EXPECT_EQ(client.request_sync(http::verb::get,"/",{},"",{}).body(),"ready"); else { try { client.request_sync(http::verb::get,"/",{},"",{}); FAIL()<<"timeout expected"; } - catch(const boost::system::system_error& error) { EXPECT_NE(std::string(error.what()).find(phase),std::string::npos); } } + catch(const boost::system::system_error& error) { + EXPECT_EQ(std::string(error.code().category().name()), "http.client.timeout"); + EXPECT_EQ(error.code().value(), phase); + } } server.get(); } -TEST(ClientTimeout, ReadDeadlineNamesPhase) { slow_response({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(1),std::chrono::seconds(2)},"read"); } -TEST(ClientTimeout, TotalDeadlineAppliesWithUnlimitedRead) { slow_response({std::chrono::seconds(1),std::chrono::seconds(2),std::chrono::seconds(0),std::chrono::seconds(2)},"total"); } -TEST(ClientTimeout, UnlimitedTotalStillHonorsReadDeadline) { slow_response({std::chrono::seconds(0),std::chrono::seconds(2),std::chrono::seconds(1),std::chrono::seconds(2)},"read"); } -TEST(ClientTimeout, ZeroReadWaitsWhenTotalAllowsIt) { slow_response({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(0),std::chrono::seconds(2)},nullptr); } +TEST(ClientTimeout, ReadDeadlineNamesPhase) { slow_response({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(1),std::chrono::seconds(2)}, 3); } +TEST(ClientTimeout, TotalDeadlineAppliesWithUnlimitedRead) { slow_response({std::chrono::seconds(1),std::chrono::seconds(2),std::chrono::seconds(0),std::chrono::seconds(2)}, 1); } +TEST(ClientTimeout, UnlimitedTotalStillHonorsReadDeadline) { slow_response({std::chrono::seconds(0),std::chrono::seconds(2),std::chrono::seconds(1),std::chrono::seconds(2)}, 3); } +TEST(ClientTimeout, ZeroReadWaitsWhenTotalAllowsIt) { slow_response({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(0),std::chrono::seconds(2)}, 0); } TEST(ClientTimeout, TlsHandshakeCoveredByConnectDeadline) { net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); auto server=std::async(std::launch::async,[&] { tcp::socket socket(io); acceptor.accept(socket); std::this_thread::sleep_for(std::chrono::milliseconds(1300)); }); @@ -1005,7 +1008,10 @@ TEST(ClientTimeout, TlsHandshakeCoveredByConnectDeadline) { client.set_base_url("https://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); client.set_timeouts({std::chrono::seconds(3),std::chrono::seconds(1),std::chrono::seconds(2),std::chrono::seconds(2)}); try {client.request_sync(http::verb::get,"/",{},"",{}); FAIL()<<"timeout expected";} - catch(const boost::system::system_error& error) {EXPECT_NE(std::string(error.what()).find("connect"),std::string::npos);} server.get(); + catch(const boost::system::system_error& error) { + EXPECT_EQ(std::string(error.code().category().name()), "http.client.timeout"); + EXPECT_EQ(error.code().value(), 2); + } server.get(); } TEST(ClientTimeout, StalledUploadCoveredByWriteDeadline) { net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); @@ -1014,5 +1020,12 @@ TEST(ClientTimeout, StalledUploadCoveredByWriteDeadline) { client.set_base_url("http://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); client.set_timeouts({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(2),std::chrono::seconds(1)}); try {client.request_sync(http::verb::post,"/",{},std::string(16*1024*1024,'x'),{}); FAIL()<<"timeout expected";} - catch(const boost::system::system_error& error) {EXPECT_NE(std::string(error.what()).find("write"),std::string::npos);} server.get(); + catch(const boost::system::system_error& error) { + EXPECT_EQ(std::string(error.code().category().name()), "http.client.timeout"); + // Windows may complete the buffered upload before the write deadline and + // then time out while waiting for the response. Both phases are valid for + // this deliberately non-reading peer; the important contract is that the + // client returns a typed timeout instead of waiting indefinitely. + EXPECT_TRUE(error.code().value() == 3 || error.code().value() == 4); + } server.get(); } From 77ec122bfc33397749ade2bc9cf175bc1ec781e3 Mon Sep 17 00:00:00 2001 From: kekxv Date: Fri, 9 Oct 2026 23:10:43 +0800 Subject: [PATCH 3/3] test(http-client): keep stalled upload peer alive --- framework/tests/client_test.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/framework/tests/client_test.cpp b/framework/tests/client_test.cpp index eb41dc8..311e6d0 100644 --- a/framework/tests/client_test.cpp +++ b/framework/tests/client_test.cpp @@ -1015,7 +1015,10 @@ TEST(ClientTimeout, TlsHandshakeCoveredByConnectDeadline) { } TEST(ClientTimeout, StalledUploadCoveredByWriteDeadline) { net::io_context io; tcp::acceptor acceptor(io,{net::ip::make_address("127.0.0.1"),0}); - auto server=std::async(std::launch::async,[&] {tcp::socket socket(io); acceptor.accept(socket); socket.set_option(net::socket_base::receive_buffer_size(1024)); std::this_thread::sleep_for(std::chrono::milliseconds(1500)); }); + auto server=std::async(std::launch::async,[&] {tcp::socket socket(io); acceptor.accept(socket); socket.set_option(net::socket_base::receive_buffer_size(1024)); + // Keep the peer alive past every client deadline. Closing at 1500ms makes + // Windows report a connection reset before the intended write/read timeout. + std::this_thread::sleep_for(std::chrono::milliseconds(4000)); }); khttpd::framework::client::HttpClient client; client.set_base_url("http://127.0.0.1:"+std::to_string(acceptor.local_endpoint().port())); client.set_timeouts({std::chrono::seconds(3),std::chrono::seconds(2),std::chrono::seconds(2),std::chrono::seconds(1)});