Repository navigation
fix(rest): make HttpClient safe for concurrent requests #1090
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,6 +20,9 @@ | |
| #include "iceberg/catalog/rest/http_client.h" | ||
|
|
||
| #include <map> | ||
| #include <mutex> | ||
| #include <utility> | ||
| #include <vector> | ||
|
|
||
| #include <cpr/cpr.h> | ||
|
|
||
|
|
@@ -34,19 +37,25 @@ namespace iceberg::rest { | |
|
|
||
| class HttpResponse::Impl { | ||
| public: | ||
| explicit Impl(cpr::Response&& response) : response_(std::move(response)) {} | ||
| explicit Impl(cpr::Response response) | ||
| : status_code_(static_cast<int32_t>(response.status_code)), | ||
| body_(std::move(response.text)), | ||
| headers_(response.header.begin(), response.header.end()) {} | ||
| ~Impl() = default; | ||
|
|
||
| int32_t status_code() const { return static_cast<int32_t>(response_.status_code); } | ||
| static Result<HttpResponse> Make(cpr::Response response, | ||
| const ErrorHandler& error_handler); | ||
|
|
||
| std::string body() const { return response_.text; } | ||
| int32_t status_code() const { return status_code_; } | ||
|
|
||
| std::unordered_map<std::string, std::string> headers() const { | ||
| return {response_.header.begin(), response_.header.end()}; | ||
| } | ||
| std::string body() const { return body_; } | ||
|
|
||
| std::unordered_map<std::string, std::string> headers() const { return headers_; } | ||
|
|
||
| private: | ||
| cpr::Response response_; | ||
| int32_t status_code_; | ||
| std::string body_; | ||
| std::unordered_map<std::string, std::string> headers_; | ||
| }; | ||
|
|
||
| HttpResponse::HttpResponse() = default; | ||
|
|
@@ -190,6 +199,50 @@ Status HandleFailureResponse(const cpr::Response& response, | |
|
|
||
| } // namespace | ||
|
|
||
| Result<HttpResponse> HttpResponse::Impl::Make(cpr::Response response, | ||
| const ErrorHandler& error_handler) { | ||
| ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); | ||
| HttpResponse http_response; | ||
| http_response.impl_ = std::make_unique<Impl>(std::move(response)); | ||
| return http_response; | ||
| } | ||
|
|
||
| // libcurl does not support using a connection cache from several threads at once | ||
| // (CURLSHOPT_SHARE(3)). | ||
| class HttpClient::ConnectionPools { | ||
| public: | ||
| template <typename Send> | ||
| Result<HttpResponse> Run(const Send& send, const ErrorHandler& error_handler) { | ||
| std::unique_ptr<cpr::ConnectionPool> pool = Acquire(); | ||
| // Make() consumes the CPR response, freeing its curl handle before pool reuse. | ||
| Result<HttpResponse> response = HttpResponse::Impl::Make(send(*pool), error_handler); | ||
| Release(std::move(pool)); | ||
| return response; | ||
| } | ||
|
|
||
| private: | ||
| std::unique_ptr<cpr::ConnectionPool> Acquire() { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This setup made sense to me, wonder if folks have thoughts on this approach? |
||
| { | ||
| std::lock_guard lock(mutex_); | ||
| if (!idle_.empty()) { | ||
| auto pool = std::move(idle_.back()); | ||
| idle_.pop_back(); | ||
| return pool; | ||
| } | ||
| } | ||
| return std::make_unique<cpr::ConnectionPool>(); | ||
| } | ||
|
|
||
| void Release(std::unique_ptr<cpr::ConnectionPool> pool) { | ||
| std::lock_guard lock(mutex_); | ||
| idle_.push_back(std::move(pool)); | ||
| } | ||
|
|
||
| std::mutex mutex_; | ||
| // LIFO: the most recently released pool is the likeliest to have a live connection. | ||
| std::vector<std::unique_ptr<cpr::ConnectionPool>> idle_; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| }; | ||
|
|
||
| HttpClient::HttpClient(std::unordered_map<std::string, std::string> default_headers, | ||
| std::chrono::milliseconds connection_timeout, | ||
| std::chrono::milliseconds socket_timeout, | ||
|
|
@@ -198,7 +251,7 @@ HttpClient::HttpClient(std::unordered_map<std::string, std::string> default_head | |
| default_headers_{std::move(default_headers)}, | ||
| connection_timeout_{connection_timeout}, | ||
| socket_timeout_{socket_timeout}, | ||
| connection_pool_{std::make_unique<cpr::ConnectionPool>()} { | ||
| connection_pools_{std::make_unique<ConnectionPools>()} { | ||
| // Set default Content-Type for all requests (including GET/HEAD/DELETE). | ||
| // Many systems require that content type is set regardless, even on an empty body. | ||
| default_headers_[kHeaderContentType] = kMimeTypeApplicationJson; | ||
|
|
@@ -234,15 +287,14 @@ Result<HttpResponse> HttpClient::Get( | |
| ICEBERG_ASSIGN_OR_RAISE(auto authenticated, | ||
| AuthenticateRequest(session, HttpMethod::kGet, std::move(url), | ||
| MergeHeaders(default_headers_, headers))); | ||
| cpr::Response response = | ||
| cpr::Get(cpr::Url{authenticated.url}, ToCprHeader(authenticated), | ||
| kNoFollowRedirects, *connection_pool_, | ||
| cpr::ConnectTimeout{connection_timeout_}, cpr::Timeout{socket_timeout_}); | ||
|
|
||
| ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); | ||
| HttpResponse http_response; | ||
| http_response.impl_ = std::make_unique<HttpResponse::Impl>(std::move(response)); | ||
| return http_response; | ||
| return connection_pools_->Run( | ||
| [&](const cpr::ConnectionPool& pool) { | ||
| return cpr::Get(cpr::Url{authenticated.url}, ToCprHeader(authenticated), | ||
| kNoFollowRedirects, pool, | ||
| cpr::ConnectTimeout{connection_timeout_}, | ||
| cpr::Timeout{socket_timeout_}); | ||
| }, | ||
| error_handler); | ||
| } | ||
|
|
||
| Result<HttpResponse> HttpClient::Post( | ||
|
|
@@ -254,15 +306,14 @@ Result<HttpResponse> HttpClient::Post( | |
| auto authenticated, | ||
| AuthenticateRequest(session, HttpMethod::kPost, std::move(request_uri), | ||
| MergeHeaders(default_headers_, headers), body)); | ||
| cpr::Response response = | ||
| cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, | ||
| ToCprHeader(authenticated), kNoFollowRedirects, *connection_pool_, | ||
| cpr::ConnectTimeout{connection_timeout_}, cpr::Timeout{socket_timeout_}); | ||
|
|
||
| ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); | ||
| HttpResponse http_response; | ||
| http_response.impl_ = std::make_unique<HttpResponse::Impl>(std::move(response)); | ||
| return http_response; | ||
| return connection_pools_->Run( | ||
| [&](const cpr::ConnectionPool& pool) { | ||
| return cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, | ||
| ToCprHeader(authenticated), kNoFollowRedirects, pool, | ||
| cpr::ConnectTimeout{connection_timeout_}, | ||
| cpr::Timeout{socket_timeout_}); | ||
| }, | ||
| error_handler); | ||
| } | ||
|
|
||
| Result<HttpResponse> HttpClient::PostForm( | ||
|
|
@@ -286,15 +337,14 @@ Result<HttpResponse> HttpClient::PostForm( | |
| AuthenticateRequest(session, HttpMethod::kPost, std::move(request_uri), | ||
| MergeHeaders(default_headers_, form_headers), | ||
| std::move(encoded_body))); | ||
| cpr::Response response = | ||
| cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, | ||
| ToCprHeader(authenticated), kNoFollowRedirects, *connection_pool_, | ||
| cpr::ConnectTimeout{connection_timeout_}, cpr::Timeout{socket_timeout_}); | ||
|
|
||
| ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); | ||
| HttpResponse http_response; | ||
| http_response.impl_ = std::make_unique<HttpResponse::Impl>(std::move(response)); | ||
| return http_response; | ||
| return connection_pools_->Run( | ||
| [&](const cpr::ConnectionPool& pool) { | ||
| return cpr::Post(cpr::Url{authenticated.url}, cpr::Body{authenticated.body}, | ||
| ToCprHeader(authenticated), kNoFollowRedirects, pool, | ||
| cpr::ConnectTimeout{connection_timeout_}, | ||
| cpr::Timeout{socket_timeout_}); | ||
| }, | ||
| error_handler); | ||
| } | ||
|
|
||
| Result<HttpResponse> HttpClient::Head( | ||
|
|
@@ -305,15 +355,14 @@ Result<HttpResponse> HttpClient::Head( | |
| auto authenticated, | ||
| AuthenticateRequest(session, HttpMethod::kHead, std::move(request_uri), | ||
| MergeHeaders(default_headers_, headers))); | ||
| cpr::Response response = | ||
| cpr::Head(cpr::Url{authenticated.url}, ToCprHeader(authenticated), | ||
| kNoFollowRedirects, *connection_pool_, | ||
| cpr::ConnectTimeout{connection_timeout_}, cpr::Timeout{socket_timeout_}); | ||
|
|
||
| ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); | ||
| HttpResponse http_response; | ||
| http_response.impl_ = std::make_unique<HttpResponse::Impl>(std::move(response)); | ||
| return http_response; | ||
| return connection_pools_->Run( | ||
| [&](const cpr::ConnectionPool& pool) { | ||
| return cpr::Head(cpr::Url{authenticated.url}, ToCprHeader(authenticated), | ||
| kNoFollowRedirects, pool, | ||
| cpr::ConnectTimeout{connection_timeout_}, | ||
| cpr::Timeout{socket_timeout_}); | ||
| }, | ||
| error_handler); | ||
| } | ||
|
|
||
| Result<HttpResponse> HttpClient::Delete( | ||
|
|
@@ -326,15 +375,14 @@ Result<HttpResponse> HttpClient::Delete( | |
| auto authenticated, | ||
| AuthenticateRequest(session, HttpMethod::kDelete, std::move(url), | ||
| MergeHeaders(default_headers_, headers))); | ||
| cpr::Response response = cpr::Delete( | ||
| cpr::Url{authenticated.url}, ToCprHeader(authenticated), kNoFollowRedirects, | ||
| *connection_pool_, cpr::ConnectTimeout{connection_timeout_}, | ||
| cpr::Timeout{socket_timeout_}); | ||
|
|
||
| ICEBERG_RETURN_UNEXPECTED(HandleFailureResponse(response, error_handler)); | ||
| HttpResponse http_response; | ||
| http_response.impl_ = std::make_unique<HttpResponse::Impl>(std::move(response)); | ||
| return http_response; | ||
| return connection_pools_->Run( | ||
| [&](const cpr::ConnectionPool& pool) { | ||
| return cpr::Delete(cpr::Url{authenticated.url}, ToCprHeader(authenticated), | ||
| kNoFollowRedirects, pool, | ||
| cpr::ConnectTimeout{connection_timeout_}, | ||
| cpr::Timeout{socket_timeout_}); | ||
| }, | ||
| error_handler); | ||
| } | ||
|
|
||
| } // namespace iceberg::rest | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| #include <cstddef> | ||
| #include <cstdint> | ||
| #include <format> | ||
| #include <memory> | ||
| #include <string> | ||
| #include <string_view> | ||
| #include <thread> | ||
|
|
@@ -301,7 +302,6 @@ TEST(HttpClientTest, PostReportsTransferFailureAfterSuccessfulStatus) { | |
| ASSERT_NO_FATAL_FAILURE(Listen(listener)); | ||
| const auto url = std::format("http://127.0.0.1:{}/v1/commit", listener.port); | ||
|
|
||
| // Outlives the response, which keeps a curl handle in its connection pool. | ||
| HttpClient client; | ||
| auto session = auth::AuthSession::MakeDefault({}); | ||
| std::thread server([&] { ServeOnce(listener.fd, response_bytes, /*end=*/true); }); | ||
|
|
@@ -312,6 +312,40 @@ TEST(HttpClientTest, PostReportsTransferFailureAfterSuccessfulStatus) { | |
| EXPECT_THAT(response, HasErrorMessage("failed after status 200")); | ||
| } | ||
|
|
||
| TEST(HttpClientTest, ResponseOutlivesLaterRequestsAndClient) { | ||
| constexpr std::string_view kBody = R"({"defaults":{}})"; | ||
| const auto response_bytes = std::format( | ||
| "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nX-Test: kept\r\n" | ||
| "Content-Length: {}\r\nConnection: close\r\n\r\n{}", | ||
| kBody.size(), kBody); | ||
| Listener listener; | ||
| ASSERT_NO_FATAL_FAILURE(Listen(listener)); | ||
| const auto url = std::format("http://127.0.0.1:{}/v1/config", listener.port); | ||
|
|
||
| auto client = std::make_unique<HttpClient>(); | ||
| auto session = auth::AuthSession::MakeDefault({}); | ||
| std::thread server([&] { | ||
| ServeOnce(listener.fd, response_bytes, /*end=*/true); | ||
| ServeOnce(listener.fd, response_bytes, /*end=*/true); | ||
| }); | ||
| auto first = client->Get(url, /*params=*/{}, /*headers=*/{}, | ||
| *DefaultErrorHandler::Instance(), *session); | ||
| auto second = client->Get(url, /*params=*/{}, /*headers=*/{}, | ||
| *DefaultErrorHandler::Instance(), *session); | ||
| server.join(); | ||
| client.reset(); | ||
|
|
||
| auto expect_intact = [&](const Result<HttpResponse>& response) { | ||
| ASSERT_THAT(response, IsOk()); | ||
| EXPECT_EQ(response->status_code(), 200); | ||
| EXPECT_EQ(response->body(), kBody); | ||
| EXPECT_THAT(response->headers(), | ||
| ::testing::Contains(::testing::Pair("X-Test", "kept"))); | ||
| }; | ||
| expect_intact(first); | ||
| expect_intact(second); | ||
| } | ||
|
|
||
| #endif | ||
|
|
||
| class CapturingAuthSession final : public auth::AuthSession { | ||
|
|
@@ -340,6 +374,22 @@ TEST(HttpClientTest, BuildsRequestUriLikeJavaHttpRequest) { | |
| *DefaultErrorHandler::Instance(), session); | ||
| EXPECT_THAT(absolute, IsError(ErrorKind::kInvalidArgument)); | ||
| EXPECT_EQ(session.request().url, "https://auth.example/token/"); | ||
|
|
||
| // A different path per method, so a call that fails before authenticating can't | ||
| // pass by matching the URL left over from the previous call. | ||
| const auto& handler = *DefaultErrorHandler::Instance(); | ||
| EXPECT_THAT(client.Get("v1/get", {}, {}, handler, session), | ||
| IsError(ErrorKind::kInvalidArgument)); | ||
| EXPECT_EQ(session.request().url, "https://catalog.example/api/v1/get"); | ||
|
Comment on lines
+381
to
+383
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See #1090 (comment) for the motivation behind these new assertions |
||
| EXPECT_THAT(client.Post("v1/post", "{}", {}, handler, session), | ||
| IsError(ErrorKind::kInvalidArgument)); | ||
| EXPECT_EQ(session.request().url, "https://catalog.example/api/v1/post"); | ||
| EXPECT_THAT(client.Head("v1/head", {}, handler, session), | ||
| IsError(ErrorKind::kInvalidArgument)); | ||
| EXPECT_EQ(session.request().url, "https://catalog.example/api/v1/head"); | ||
| EXPECT_THAT(client.Delete("v1/delete", {}, {}, handler, session), | ||
| IsError(ErrorKind::kInvalidArgument)); | ||
| EXPECT_EQ(session.request().url, "https://catalog.example/api/v1/delete"); | ||
| } | ||
|
|
||
| // Redirect responses must be reported without replaying the request at the target. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| #include <string> | ||
| #include <thread> | ||
| #include <unordered_map> | ||
| #include <vector> | ||
|
|
||
| #include <arpa/inet.h> | ||
| #include <gmock/gmock.h> | ||
|
|
@@ -425,6 +426,31 @@ TEST_F(RestCatalogIntegrationTest, FetchServerConfigDirect) { | |
| } | ||
| } | ||
|
|
||
| // RestCatalog shares its HttpClient with token refresh and metrics reporting. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With libcurl 8.22.0, this fails in 6 of 6 runs with heap corruption (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this is sufficient? If a regression test is preferable I can try to think of other approaches but I don't see anything easy for me to achieve that right now
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I can see that the gh actions runner has 8.18.0-1ubuntu2.8 installed[1]. You mean without your fix this fails, right? [1] https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2604-Readme.md
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah realise I wasn't super clear: with 8.22.0 this fails on current main (without this PR's fix) fairly reliable as far as I can tell. With this fix, it passes. But CI runs 8.18, as you say for which this bug doesn't surface for this test. (Given we're violating libcurl's concurrency precondition, it doesn't provide us with fixed erroneous behaviour across versions for regression testing hence it's a bit annoying)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I get it, thanks |
||
| TEST_F(RestCatalogIntegrationTest, ConcurrentHttpClientRequests) { | ||
| constexpr int kThreads = 32; | ||
| constexpr int kRequestsPerThread = 200; | ||
|
|
||
| HttpClient client({}, std::chrono::seconds(30), std::chrono::seconds(30)); | ||
| auto noop_session = auth::AuthSession::MakeDefault({}); | ||
| const std::string config_url = std::format("{}/v1/config", CatalogUri()); | ||
|
|
||
| std::vector<std::thread> threads; | ||
| threads.reserve(kThreads); | ||
| for (int i = 0; i < kThreads; ++i) { | ||
| threads.emplace_back([&] { | ||
| for (int j = 0; j < kRequestsPerThread; ++j) { | ||
| ASSERT_THAT(client.Get(config_url, {}, /*headers=*/{}, | ||
| *DefaultErrorHandler::Instance(), *noop_session), | ||
| IsOk()); | ||
| } | ||
| }); | ||
| } | ||
| for (auto& thread : threads) { | ||
| thread.join(); | ||
| } | ||
| } | ||
|
|
||
| // -- Namespace operations -- | ||
|
|
||
| TEST_F(RestCatalogIntegrationTest, ListNamespaces) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changes in this class are due to this. The cpr::Response maintains a curl handle so destruction affects the pool. I'm therefore instead storing what's needed explicitly. See below / PR description for more details