Skip to content

Commit c6824e5

Browse files
committed
MOD: Improve HTTP error parsing in Rust and C++
1 parent 6a86cf1 commit c6824e5

6 files changed

Lines changed: 163 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Changelog
22

3+
## 0.57.0 - TBD
4+
5+
### Enhancements
6+
- Added `Case()`, `DetailMessage()`, and `DocsUrl()` getters on `HttpResponseError`.
7+
These parse the JSON error envelope returned by the historical API and expose its
8+
fields directly
9+
- Included the error `case` (when present) in the message returned by
10+
`HttpResponseError::what()`
11+
12+
### Breaking changes
13+
- Removed `HttpResponseError::ResponseBody()`. The raw body is still embedded in the
14+
message returned by `what()`; use the new `Case()`, `DetailMessage()`, and `DocsUrl()`
15+
getters for structured access
16+
317
## 0.56.0 - 2026-05-05
418

519
### Enhancements

include/databento/exceptions.hpp

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <chrono>
1010
#include <cstdint>
1111
#include <exception>
12+
#include <optional>
1213
#include <string>
1314
#include <string_view>
1415
#include <utility> // move
@@ -48,26 +49,43 @@ class HttpRequestError : public Exception {
4849
// server.
4950
class HttpResponseError : public Exception {
5051
public:
51-
HttpResponseError(std::string request_path, std::int32_t status_code,
52-
std::string response_body)
53-
: Exception{BuildMessage(request_path, status_code, response_body)},
54-
request_path_{std::move(request_path)},
55-
status_code_{status_code},
56-
response_body_{std::move(response_body)} {}
52+
HttpResponseError(const std::string& request_path, std::int32_t status_code,
53+
const std::string& response_body);
5754

5855
const std::string& RequestPath() const { return request_path_; }
5956
std::int32_t StatusCode() const { return status_code_; }
60-
const std::string& ResponseBody() const { return response_body_; }
57+
// Machine-readable error case from the server's JSON envelope, or `nullopt` if
58+
// the response body was not a structured error.
59+
const std::optional<std::string>& Case() const { return case_; }
60+
// Server-provided message extracted from the JSON envelope, or `nullopt` if
61+
// the body was not parseable as one.
62+
const std::optional<std::string>& DetailMessage() const { return detail_message_; }
63+
// Documentation URL from the JSON envelope, or `nullopt` if absent.
64+
const std::optional<std::string>& DocsUrl() const { return docs_url_; }
6165

6266
private:
67+
struct ParsedDetail {
68+
std::optional<std::string> case_str;
69+
std::optional<std::string> detail_message;
70+
std::optional<std::string> docs_url;
71+
};
72+
73+
static ParsedDetail ParseDetail(std::string_view request_path,
74+
const std::string& response_body);
6375
static std::string BuildMessage(std::string_view request_path,
6476
std::int32_t status_code,
65-
std::string_view response_body);
77+
std::string_view response_body,
78+
const std::optional<std::string>& case_str);
79+
80+
HttpResponseError(std::string request_path, std::int32_t status_code,
81+
const std::string& response_body, ParsedDetail parsed);
6682

6783
const std::string request_path_;
6884
// int32 is the representation used by httplib
6985
const std::int32_t status_code_;
70-
const std::string response_body_;
86+
const std::optional<std::string> case_;
87+
const std::optional<std::string> detail_message_;
88+
const std::optional<std::string> docs_url_;
7189
};
7290

7391
// Exception indicating an issue with the TCP connection.

src/detail/http_client.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ std::unique_ptr<databento::IReadable> HttpClient::OpenPostStream(
125125
while ((n = handle.read(buf.data(), buf.size())) > 0) {
126126
err_body.append(buf.data(), static_cast<std::size_t>(n));
127127
}
128-
throw HttpResponseError{path, handle.response->status, std::move(err_body)};
128+
throw HttpResponseError{path, handle.response->status, err_body};
129129
}
130130
return std::make_unique<HttpStreamReader>(std::move(handle));
131131
}
@@ -144,7 +144,7 @@ void HttpClient::CheckStatusAndStreamRes(const std::string& path, int status_cod
144144
std::string&& err_body,
145145
const httplib::Result& res) {
146146
if (status_code > 0) {
147-
throw HttpResponseError{path, status_code, std::move(err_body)};
147+
throw HttpResponseError{path, status_code, err_body};
148148
}
149149
if (res.error() != httplib::Error::Success &&
150150
// canceled happens if `callback` returns false, which is based on the
@@ -162,7 +162,7 @@ nlohmann::json HttpClient::CheckAndParseResponse(const std::string& path,
162162
auto& response = res.value();
163163
const auto status_code = response.status;
164164
if (HttpClient::IsErrorStatus(status_code)) {
165-
throw HttpResponseError{path, status_code, std::move(response.body)};
165+
throw HttpResponseError{path, status_code, response.body};
166166
}
167167
CheckWarnings(response);
168168
try {

src/exceptions.cpp

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,15 @@
55
#else
66
#include <cstring> // strerror
77
#endif
8+
#include <nlohmann/json.hpp>
9+
10+
#include <exception> // exception
11+
#include <optional>
812
#include <sstream> // ostringstream
913
#include <utility> // move
1014

15+
#include "databento/detail/json_helpers.hpp"
16+
1117
using databento::HttpRequestError;
1218

1319
std::string HttpRequestError::BuildMessage(std::string_view request_path,
@@ -32,12 +38,58 @@ std::string TcpError::BuildMessage(int err_num, std::string message) {
3238

3339
using databento::HttpResponseError;
3440

35-
std::string HttpResponseError::BuildMessage(std::string_view request_path,
36-
std::int32_t status_code,
37-
std::string_view response_body) {
41+
HttpResponseError::HttpResponseError(const std::string& request_path,
42+
std::int32_t status_code,
43+
const std::string& response_body)
44+
: HttpResponseError{request_path, status_code, response_body,
45+
ParseDetail(request_path, response_body)} {}
46+
47+
HttpResponseError::HttpResponseError(std::string request_path, std::int32_t status_code,
48+
const std::string& response_body,
49+
ParsedDetail parsed)
50+
: Exception{
51+
BuildMessage(request_path, status_code, response_body, parsed.case_str)},
52+
request_path_{std::move(request_path)},
53+
status_code_{status_code},
54+
case_{std::move(parsed.case_str)},
55+
detail_message_{std::move(parsed.detail_message)},
56+
docs_url_{std::move(parsed.docs_url)} {}
57+
58+
HttpResponseError::ParsedDetail HttpResponseError::ParseDetail(
59+
std::string_view request_path, const std::string& response_body) {
60+
// Best-effort parse of the historical API rich error format
61+
// {"detail": {"case": "...", "message": "...", "docs": "...", "payload": {...}}}
62+
// Falls back to {"detail": "..."} or leaves all fields empty for non-JSON bodies
63+
ParsedDetail out;
64+
try {
65+
const auto json = nlohmann::json::parse(response_body);
66+
const auto& detail = detail::CheckedAt(request_path, json, "detail");
67+
if (detail.is_string()) {
68+
out.detail_message = detail.get<std::string>();
69+
} else if (detail.is_object()) {
70+
out.case_str =
71+
detail::ParseAt<std::optional<std::string>>(request_path, detail, "case");
72+
out.detail_message =
73+
detail::ParseAt<std::optional<std::string>>(request_path, detail, "message");
74+
out.docs_url =
75+
detail::ParseAt<std::optional<std::string>>(request_path, detail, "docs");
76+
}
77+
} catch (const std::exception&) { // NOLINT(bugprone-empty-catch)
78+
// Body wasn't JSON, didn't have a `detail` key, or had unexpected types; the
79+
// raw body is still embedded in `what()`, so leave the parsed fields empty.
80+
}
81+
return out;
82+
}
83+
84+
std::string HttpResponseError::BuildMessage(
85+
std::string_view request_path, std::int32_t status_code,
86+
std::string_view response_body, const std::optional<std::string>& case_str) {
3887
std::ostringstream err_msg;
3988
err_msg << "Received an error response from request to " << request_path
4089
<< " with status " << status_code << " and body '" << response_body << '\'';
90+
if (case_str) {
91+
err_msg << " (case: " << *case_str << ')';
92+
}
4193
return err_msg.str();
4294
}
4395

tests/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ set(
3333
src/dbn_encoder_tests.cpp
3434
src/dbn_file_store_tests.cpp
3535
src/dbn_tests.cpp
36+
src/exception_tests.cpp
3637
src/file_stream_tests.cpp
3738
src/flag_set_tests.cpp
3839
src/historical_tests.cpp

tests/src/exception_tests.cpp

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#include <gtest/gtest.h>
2+
3+
#include <string>
4+
5+
#include "databento/exceptions.hpp"
6+
7+
namespace databento::tests {
8+
9+
TEST(HttpResponseErrorTests, SimpleDetail) {
10+
const std::string body =
11+
R"({"detail": "Authorization failed: illegal chars in username."})";
12+
const HttpResponseError err{"/v0/timeseries.get_range", 400, body};
13+
14+
EXPECT_EQ(err.StatusCode(), 400);
15+
EXPECT_EQ(err.RequestPath(), "/v0/timeseries.get_range");
16+
EXPECT_FALSE(err.Case().has_value());
17+
ASSERT_TRUE(err.DetailMessage().has_value());
18+
EXPECT_EQ(*err.DetailMessage(), "Authorization failed: illegal chars in username.");
19+
EXPECT_FALSE(err.DocsUrl().has_value());
20+
// Simple-detail responses don't append a `(case: ...)` suffix.
21+
EXPECT_EQ(std::string{err.what()},
22+
"Received an error response from request to /v0/timeseries.get_range "
23+
"with status 400 and body '" +
24+
body + "'");
25+
}
26+
27+
TEST(HttpResponseErrorTests, BusinessDetail) {
28+
const std::string body =
29+
R"({"detail": {)"
30+
R"("case": "data_start_before_available_start",)"
31+
R"("message": "start was before the available start.",)"
32+
R"("status_code": 422,)"
33+
R"("docs": "https://databento.com/docs/api-reference-historical/metadata/metadata-get-dataset",)"
34+
R"("payload": {"dataset": "GLBX.MDP3"}}})";
35+
const HttpResponseError err{"/v0/timeseries.get_range", 422, body};
36+
37+
EXPECT_EQ(err.StatusCode(), 422);
38+
ASSERT_TRUE(err.Case().has_value());
39+
EXPECT_EQ(*err.Case(), "data_start_before_available_start");
40+
ASSERT_TRUE(err.DetailMessage().has_value());
41+
EXPECT_EQ(*err.DetailMessage(), "start was before the available start.");
42+
ASSERT_TRUE(err.DocsUrl().has_value());
43+
EXPECT_EQ(*err.DocsUrl(),
44+
"https://databento.com/docs/api-reference-historical/metadata/"
45+
"metadata-get-dataset");
46+
// The raw body is still embedded in `what()` for debugging, even though the
47+
// typed accessors expose the parsed envelope.
48+
EXPECT_NE(std::string{err.what()}.find(body), std::string::npos);
49+
EXPECT_NE(std::string{err.what()}.find("(case: data_start_before_available_start)"),
50+
std::string::npos);
51+
}
52+
53+
TEST(HttpResponseErrorTests, NonJsonBody) {
54+
const std::string body = "<html>502 Bad Gateway</html>";
55+
const HttpResponseError err{"/v0/metadata.list_datasets", 502, body};
56+
57+
EXPECT_EQ(err.StatusCode(), 502);
58+
EXPECT_NE(std::string{err.what()}.find(body), std::string::npos);
59+
EXPECT_FALSE(err.Case().has_value());
60+
EXPECT_FALSE(err.DetailMessage().has_value());
61+
EXPECT_FALSE(err.DocsUrl().has_value());
62+
}
63+
} // namespace databento::tests

0 commit comments

Comments
 (0)