From c6824e5e07035784ef317e5baf81951fcf07bd59 Mon Sep 17 00:00:00 2001 From: Carter Green Date: Fri, 8 May 2026 13:07:21 -0500 Subject: [PATCH 1/2] MOD: Improve HTTP error parsing in Rust and C++ --- CHANGELOG.md | 14 +++++++ include/databento/exceptions.hpp | 36 +++++++++++++----- src/detail/http_client.cpp | 6 +-- src/exceptions.cpp | 58 +++++++++++++++++++++++++++-- tests/CMakeLists.txt | 1 + tests/src/exception_tests.cpp | 63 ++++++++++++++++++++++++++++++++ 6 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 tests/src/exception_tests.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a3a11..cbcdf95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.57.0 - TBD + +### Enhancements +- Added `Case()`, `DetailMessage()`, and `DocsUrl()` getters on `HttpResponseError`. + These parse the JSON error envelope returned by the historical API and expose its + fields directly +- Included the error `case` (when present) in the message returned by + `HttpResponseError::what()` + +### Breaking changes +- Removed `HttpResponseError::ResponseBody()`. The raw body is still embedded in the + message returned by `what()`; use the new `Case()`, `DetailMessage()`, and `DocsUrl()` + getters for structured access + ## 0.56.0 - 2026-05-05 ### Enhancements diff --git a/include/databento/exceptions.hpp b/include/databento/exceptions.hpp index 88a9b78..ab0eadd 100644 --- a/include/databento/exceptions.hpp +++ b/include/databento/exceptions.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include // move @@ -48,26 +49,43 @@ class HttpRequestError : public Exception { // server. class HttpResponseError : public Exception { public: - HttpResponseError(std::string request_path, std::int32_t status_code, - std::string response_body) - : Exception{BuildMessage(request_path, status_code, response_body)}, - request_path_{std::move(request_path)}, - status_code_{status_code}, - response_body_{std::move(response_body)} {} + HttpResponseError(const std::string& request_path, std::int32_t status_code, + const std::string& response_body); const std::string& RequestPath() const { return request_path_; } std::int32_t StatusCode() const { return status_code_; } - const std::string& ResponseBody() const { return response_body_; } + // Machine-readable error case from the server's JSON envelope, or `nullopt` if + // the response body was not a structured error. + const std::optional& Case() const { return case_; } + // Server-provided message extracted from the JSON envelope, or `nullopt` if + // the body was not parseable as one. + const std::optional& DetailMessage() const { return detail_message_; } + // Documentation URL from the JSON envelope, or `nullopt` if absent. + const std::optional& DocsUrl() const { return docs_url_; } private: + struct ParsedDetail { + std::optional case_str; + std::optional detail_message; + std::optional docs_url; + }; + + static ParsedDetail ParseDetail(std::string_view request_path, + const std::string& response_body); static std::string BuildMessage(std::string_view request_path, std::int32_t status_code, - std::string_view response_body); + std::string_view response_body, + const std::optional& case_str); + + HttpResponseError(std::string request_path, std::int32_t status_code, + const std::string& response_body, ParsedDetail parsed); const std::string request_path_; // int32 is the representation used by httplib const std::int32_t status_code_; - const std::string response_body_; + const std::optional case_; + const std::optional detail_message_; + const std::optional docs_url_; }; // Exception indicating an issue with the TCP connection. diff --git a/src/detail/http_client.cpp b/src/detail/http_client.cpp index 630026c..86ae1c2 100644 --- a/src/detail/http_client.cpp +++ b/src/detail/http_client.cpp @@ -125,7 +125,7 @@ std::unique_ptr HttpClient::OpenPostStream( while ((n = handle.read(buf.data(), buf.size())) > 0) { err_body.append(buf.data(), static_cast(n)); } - throw HttpResponseError{path, handle.response->status, std::move(err_body)}; + throw HttpResponseError{path, handle.response->status, err_body}; } return std::make_unique(std::move(handle)); } @@ -144,7 +144,7 @@ void HttpClient::CheckStatusAndStreamRes(const std::string& path, int status_cod std::string&& err_body, const httplib::Result& res) { if (status_code > 0) { - throw HttpResponseError{path, status_code, std::move(err_body)}; + throw HttpResponseError{path, status_code, err_body}; } if (res.error() != httplib::Error::Success && // canceled happens if `callback` returns false, which is based on the @@ -162,7 +162,7 @@ nlohmann::json HttpClient::CheckAndParseResponse(const std::string& path, auto& response = res.value(); const auto status_code = response.status; if (HttpClient::IsErrorStatus(status_code)) { - throw HttpResponseError{path, status_code, std::move(response.body)}; + throw HttpResponseError{path, status_code, response.body}; } CheckWarnings(response); try { diff --git a/src/exceptions.cpp b/src/exceptions.cpp index 14790c3..9662e5c 100644 --- a/src/exceptions.cpp +++ b/src/exceptions.cpp @@ -5,9 +5,15 @@ #else #include // strerror #endif +#include + +#include // exception +#include #include // ostringstream #include // move +#include "databento/detail/json_helpers.hpp" + using databento::HttpRequestError; std::string HttpRequestError::BuildMessage(std::string_view request_path, @@ -32,12 +38,58 @@ std::string TcpError::BuildMessage(int err_num, std::string message) { using databento::HttpResponseError; -std::string HttpResponseError::BuildMessage(std::string_view request_path, - std::int32_t status_code, - std::string_view response_body) { +HttpResponseError::HttpResponseError(const std::string& request_path, + std::int32_t status_code, + const std::string& response_body) + : HttpResponseError{request_path, status_code, response_body, + ParseDetail(request_path, response_body)} {} + +HttpResponseError::HttpResponseError(std::string request_path, std::int32_t status_code, + const std::string& response_body, + ParsedDetail parsed) + : Exception{ + BuildMessage(request_path, status_code, response_body, parsed.case_str)}, + request_path_{std::move(request_path)}, + status_code_{status_code}, + case_{std::move(parsed.case_str)}, + detail_message_{std::move(parsed.detail_message)}, + docs_url_{std::move(parsed.docs_url)} {} + +HttpResponseError::ParsedDetail HttpResponseError::ParseDetail( + std::string_view request_path, const std::string& response_body) { + // Best-effort parse of the historical API rich error format + // {"detail": {"case": "...", "message": "...", "docs": "...", "payload": {...}}} + // Falls back to {"detail": "..."} or leaves all fields empty for non-JSON bodies + ParsedDetail out; + try { + const auto json = nlohmann::json::parse(response_body); + const auto& detail = detail::CheckedAt(request_path, json, "detail"); + if (detail.is_string()) { + out.detail_message = detail.get(); + } else if (detail.is_object()) { + out.case_str = + detail::ParseAt>(request_path, detail, "case"); + out.detail_message = + detail::ParseAt>(request_path, detail, "message"); + out.docs_url = + detail::ParseAt>(request_path, detail, "docs"); + } + } catch (const std::exception&) { // NOLINT(bugprone-empty-catch) + // Body wasn't JSON, didn't have a `detail` key, or had unexpected types; the + // raw body is still embedded in `what()`, so leave the parsed fields empty. + } + return out; +} + +std::string HttpResponseError::BuildMessage( + std::string_view request_path, std::int32_t status_code, + std::string_view response_body, const std::optional& case_str) { std::ostringstream err_msg; err_msg << "Received an error response from request to " << request_path << " with status " << status_code << " and body '" << response_body << '\''; + if (case_str) { + err_msg << " (case: " << *case_str << ')'; + } return err_msg.str(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e4ad596..e270d31 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -33,6 +33,7 @@ set( src/dbn_encoder_tests.cpp src/dbn_file_store_tests.cpp src/dbn_tests.cpp + src/exception_tests.cpp src/file_stream_tests.cpp src/flag_set_tests.cpp src/historical_tests.cpp diff --git a/tests/src/exception_tests.cpp b/tests/src/exception_tests.cpp new file mode 100644 index 0000000..92817d8 --- /dev/null +++ b/tests/src/exception_tests.cpp @@ -0,0 +1,63 @@ +#include + +#include + +#include "databento/exceptions.hpp" + +namespace databento::tests { + +TEST(HttpResponseErrorTests, SimpleDetail) { + const std::string body = + R"({"detail": "Authorization failed: illegal chars in username."})"; + const HttpResponseError err{"/v0/timeseries.get_range", 400, body}; + + EXPECT_EQ(err.StatusCode(), 400); + EXPECT_EQ(err.RequestPath(), "/v0/timeseries.get_range"); + EXPECT_FALSE(err.Case().has_value()); + ASSERT_TRUE(err.DetailMessage().has_value()); + EXPECT_EQ(*err.DetailMessage(), "Authorization failed: illegal chars in username."); + EXPECT_FALSE(err.DocsUrl().has_value()); + // Simple-detail responses don't append a `(case: ...)` suffix. + EXPECT_EQ(std::string{err.what()}, + "Received an error response from request to /v0/timeseries.get_range " + "with status 400 and body '" + + body + "'"); +} + +TEST(HttpResponseErrorTests, BusinessDetail) { + const std::string body = + R"({"detail": {)" + R"("case": "data_start_before_available_start",)" + R"("message": "start was before the available start.",)" + R"("status_code": 422,)" + R"("docs": "https://databento.com/docs/api-reference-historical/metadata/metadata-get-dataset",)" + R"("payload": {"dataset": "GLBX.MDP3"}}})"; + const HttpResponseError err{"/v0/timeseries.get_range", 422, body}; + + EXPECT_EQ(err.StatusCode(), 422); + ASSERT_TRUE(err.Case().has_value()); + EXPECT_EQ(*err.Case(), "data_start_before_available_start"); + ASSERT_TRUE(err.DetailMessage().has_value()); + EXPECT_EQ(*err.DetailMessage(), "start was before the available start."); + ASSERT_TRUE(err.DocsUrl().has_value()); + EXPECT_EQ(*err.DocsUrl(), + "https://databento.com/docs/api-reference-historical/metadata/" + "metadata-get-dataset"); + // The raw body is still embedded in `what()` for debugging, even though the + // typed accessors expose the parsed envelope. + EXPECT_NE(std::string{err.what()}.find(body), std::string::npos); + EXPECT_NE(std::string{err.what()}.find("(case: data_start_before_available_start)"), + std::string::npos); +} + +TEST(HttpResponseErrorTests, NonJsonBody) { + const std::string body = "502 Bad Gateway"; + const HttpResponseError err{"/v0/metadata.list_datasets", 502, body}; + + EXPECT_EQ(err.StatusCode(), 502); + EXPECT_NE(std::string{err.what()}.find(body), std::string::npos); + EXPECT_FALSE(err.Case().has_value()); + EXPECT_FALSE(err.DetailMessage().has_value()); + EXPECT_FALSE(err.DocsUrl().has_value()); +} +} // namespace databento::tests From 53067a7e213efcf9e04145026299c8dbcf1a11e6 Mon Sep 17 00:00:00 2001 From: Carter Green Date: Tue, 12 May 2026 09:04:25 -0500 Subject: [PATCH 2/2] VER: Release 0.57.0 --- CHANGELOG.md | 2 +- CMakeLists.txt | 2 +- pkg/PKGBUILD | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbcdf95..d448488 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.57.0 - TBD +## 0.57.0 - 2026-05-12 ### Enhancements - Added `Case()`, `DetailMessage()`, and `DocsUrl()` getters on `HttpResponseError`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 420a6f1..605f65d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,7 +6,7 @@ cmake_minimum_required(VERSION 3.24..4.2) project( databento - VERSION 0.56.0 + VERSION 0.57.0 LANGUAGES CXX DESCRIPTION "Official Databento client library" ) diff --git a/pkg/PKGBUILD b/pkg/PKGBUILD index fb75728..92d7d34 100644 --- a/pkg/PKGBUILD +++ b/pkg/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: Databento _pkgname=databento-cpp pkgname=databento-cpp-git -pkgver=0.56.0 +pkgver=0.57.0 pkgrel=1 pkgdesc="Official C++ client for Databento" arch=('any')