Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 101 additions & 53 deletions src/iceberg/catalog/rest/http_client.cc
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@
#include "iceberg/catalog/rest/http_client.h"

#include <map>
#include <mutex>
#include <utility>
#include <vector>

#include <cpr/cpr.h>

Expand All @@ -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_;
Comment on lines -49 to +58

Copy link
Copy Markdown
Contributor Author

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

};

HttpResponse::HttpResponse() = default;
Expand Down Expand Up @@ -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() {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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_;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idle_ never shrinks, it ends up as large as the peak number of concurrent requests, but I think it's fine for now :)

};

HttpClient::HttpClient(std::unordered_map<std::string, std::string> default_headers,
std::chrono::milliseconds connection_timeout,
std::chrono::milliseconds socket_timeout,
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -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
17 changes: 7 additions & 10 deletions src/iceberg/catalog/rest/http_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,9 @@
/// \file iceberg/catalog/rest/http_client.h
/// \brief Http client for Iceberg REST API.

namespace cpr {
class ConnectionPool;
} // namespace cpr

namespace iceberg::rest {

/// \brief A simple wrapper for cpr::Response.
///
/// This class encapsulates the details of the underlying cpr library's response,
/// providing a consistent interface that is independent of the specific network
/// library used.
/// \brief HTTP response status, body, and headers.
class ICEBERG_REST_EXPORT HttpResponse {
public:
HttpResponse();
Expand Down Expand Up @@ -70,6 +62,9 @@ class ICEBERG_REST_EXPORT HttpResponse {
};

/// \brief HTTP client for making requests to Iceberg REST Catalog API.
///
/// Thread-safe, provided the AuthSession passed to each request supports
/// concurrent calls to Authenticate().
class ICEBERG_REST_EXPORT HttpClient {
public:
/// \param connection_timeout,socket_timeout Zero leaves requests unbounded.
Expand Down Expand Up @@ -116,14 +111,16 @@ class ICEBERG_REST_EXPORT HttpClient {
const ErrorHandler& error_handler, auth::AuthSession& session);

private:
class ConnectionPools;

/// \brief Build a request URI from this client's base URI and a path.
Result<std::string> RequestUri(const std::string& path) const;

std::optional<std::string> base_uri_;
std::unordered_map<std::string, std::string> default_headers_;
std::chrono::milliseconds connection_timeout_;
std::chrono::milliseconds socket_timeout_;
std::unique_ptr<cpr::ConnectionPool> connection_pool_;
std::unique_ptr<ConnectionPools> connection_pools_;
};

} // namespace iceberg::rest
52 changes: 51 additions & 1 deletion src/iceberg/test/http_client_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include <cstddef>
#include <cstdint>
#include <format>
#include <memory>
#include <string>
#include <string_view>
#include <thread>
Expand Down Expand Up @@ -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); });
Expand All @@ -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 {
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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.
Expand Down
26 changes: 26 additions & 0 deletions src/iceberg/test/rest_catalog_integration_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>

#include <arpa/inet.h>
#include <gmock/gmock.h>
Expand Down Expand Up @@ -425,6 +426,31 @@ TEST_F(RestCatalogIntegrationTest, FetchServerConfigDirect) {
}
}

// RestCatalog shares its HttpClient with token refresh and metrics reporting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 (free(): corrupted unsorted chunks or double free or corruption) on main. But with 8.18.0 specifically it doesn't reproduce. That's the version CI uses, so CI doesn't catch it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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) {
Expand Down
3 changes: 1 addition & 2 deletions src/iceberg/test/rest_table_scan_test.cc
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ MATCHER_P(JsonBodyLacks, key, "") {

// --------------------------------------------------------------------------
// Mock HTTP client that overrides the virtual methods of HttpClient.
// The base class constructor creates a cpr::ConnectionPool, which is a
// lightweight allocation (no network connections are opened at construction).
// The base class constructor opens no network connections.
// --------------------------------------------------------------------------
class MockHttpClient : public HttpClient {
public:
Expand Down
Loading