Skip to content
Merged
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## 0.57.0 - 2026-05-12

### 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
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down
36 changes: 27 additions & 9 deletions include/databento/exceptions.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <chrono>
#include <cstdint>
#include <exception>
#include <optional>
#include <string>
#include <string_view>
#include <utility> // move
Expand Down Expand Up @@ -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<std::string>& 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<std::string>& DetailMessage() const { return detail_message_; }
// Documentation URL from the JSON envelope, or `nullopt` if absent.
const std::optional<std::string>& DocsUrl() const { return docs_url_; }

private:
struct ParsedDetail {
std::optional<std::string> case_str;
std::optional<std::string> detail_message;
std::optional<std::string> 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<std::string>& 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<std::string> case_;
const std::optional<std::string> detail_message_;
const std::optional<std::string> docs_url_;
};

// Exception indicating an issue with the TCP connection.
Expand Down
2 changes: 1 addition & 1 deletion pkg/PKGBUILD
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Maintainer: Databento <[email protected]>
_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')
Expand Down
6 changes: 3 additions & 3 deletions src/detail/http_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ std::unique_ptr<databento::IReadable> HttpClient::OpenPostStream(
while ((n = handle.read(buf.data(), buf.size())) > 0) {
err_body.append(buf.data(), static_cast<std::size_t>(n));
}
throw HttpResponseError{path, handle.response->status, std::move(err_body)};
throw HttpResponseError{path, handle.response->status, err_body};
}
return std::make_unique<HttpStreamReader>(std::move(handle));
}
Expand All @@ -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
Expand All @@ -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 {
Expand Down
58 changes: 55 additions & 3 deletions src/exceptions.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,15 @@
#else
#include <cstring> // strerror
#endif
#include <nlohmann/json.hpp>

#include <exception> // exception
#include <optional>
#include <sstream> // ostringstream
#include <utility> // move

#include "databento/detail/json_helpers.hpp"

using databento::HttpRequestError;

std::string HttpRequestError::BuildMessage(std::string_view request_path,
Expand All @@ -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<std::string>();
} else if (detail.is_object()) {
out.case_str =
detail::ParseAt<std::optional<std::string>>(request_path, detail, "case");
out.detail_message =
detail::ParseAt<std::optional<std::string>>(request_path, detail, "message");
out.docs_url =
detail::ParseAt<std::optional<std::string>>(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<std::string>& 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();
}

Expand Down
1 change: 1 addition & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions tests/src/exception_tests.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#include <gtest/gtest.h>

#include <string>

#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 = "<html>502 Bad Gateway</html>";
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
Loading