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
12 changes: 9 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- An ArcGIS error object with code 503 was `ServerError`. It is now
`RateLimited`, the same as an HTTP 503 and as the README and `error.hpp`
say.

## [0.4.2] - 2026-09-25

### Changed
Expand All @@ -24,9 +30,9 @@ uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Builds without floating-point `std::from_chars` (libc++ before 20, and
macOS targets before 26) parsed numeric strings such as probability labels
with a string stream, which disagreed with every other build. It read
`0x10` as 16 and rejected `12abc`, `inf`, `nan`, subnormals such as
`1e-310`, and a bare trailing exponent such as `5e`, so those fields became
0 on those builds. Every platform now uses the fast_float code that Glaze
`0x10` as 16 and rejected `12abc`, subnormals such as `1e-310`, and a
bare trailing exponent such as `5e`, so those fields became 0 on those
builds. Every platform now uses the fast_float code that Glaze
already bundles, which also parses a number in about a tenth of the
stream's instructions.
- The `parse_*` functions could read past the end of the `std::string_view`
Expand Down
4 changes: 2 additions & 2 deletions src/api/client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,8 @@ struct ArcGISClient::Impl {
}

/// Page through `query`, build each page's payload from its tree with
/// `build`, and move the page's `items` into `payload`. The body is freed
/// first, so peak memory is one page's tree plus the result.
/// `build`, and move the page's `items` into `payload`. Each page's body is
/// freed before its payload is built, so the two are never alive together.
template <typename Payload, typename Item, typename Build>
[[nodiscard]] Result<Payload> paged_into(const LayerQuery& query, Payload payload,
std::vector<Item> Payload::*items,
Expand Down
3 changes: 2 additions & 1 deletion src/core/error.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@ Error Error::from_arcgis(int arcgis_code, const std::string& body) {
if (arcgis_code == 404) {
// A retired service path. An empty product is HTTP 200 with no features.
err.code = ErrorCode::NotFound;
} else if (arcgis_code == 429) {
} else if (arcgis_code == 429 || arcgis_code == 503) {
// The same codes as from_response.
err.code = ErrorCode::RateLimited;
} else if (arcgis_code >= 500 && arcgis_code <= 599) {
err.code = ErrorCode::ServerError;
Expand Down
24 changes: 24 additions & 0 deletions tests/test_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
#include "support/fixtures.hpp"

#include <cstdint>
#include <format>
#include <gtest/gtest.h>
#include <memory>
#include <string>
Expand Down Expand Up @@ -260,6 +261,8 @@ TEST(ArcGISClientPaging, EncodesParametersAndFetchesEveryPage) {

ASSERT_TRUE(result);
ASSERT_EQ(result->size(), 2u);
EXPECT_EQ((*result)[0], truncated_page(2000));
EXPECT_EQ((*result)[1], R"({"features":[],"exceededTransferLimit":false})");
ASSERT_EQ(transport->requests.size(), 2u);
EXPECT_NE(transport->requests[0].find("where=LABEL%20%3D%20%27SLGT%27"), std::string::npos);
EXPECT_NE(transport->requests[0].find("outFields=LABEL%2Cvalid"), std::string::npos);
Expand Down Expand Up @@ -371,6 +374,27 @@ TEST(ArcGISClientPaging, KeepsTheArcGISErrorDetails) {
EXPECT_EQ(result.error().detail, "'where' parameter is invalid.; Check field names.");
}

TEST(ArcGISClientPaging, ClassifiesArcGIS5xxCodesLikeHttpStatuses) {
// HTTP 503 is RateLimited and other 5xx are ServerError, so the same codes
// in an ArcGIS error object mean the same.
for (const auto& [code, expected] :
{std::pair{503, ErrorCode::RateLimited}, std::pair{500, ErrorCode::ServerError}}) {
std::shared_ptr<RecordingTransport> transport = std::make_shared<RecordingTransport>();
transport->responses = {
{200,
std::format(R"({{"error":{{"code":{},"message":"Busy","details":[]}}}})", code),
{}},
};
const ArcGISClient client{transport};

const Result<CategoricalOutlookPayload> result = client.query_categorical(1);

ASSERT_FALSE(result) << code;
EXPECT_EQ(result.error().code, expected) << code;
EXPECT_EQ(result.error().http_status, code);
}
}

// ===== What a 404 means =====
//
// SPC's static products answer 404 when nothing is issued. Any other 404 is a
Expand Down
5 changes: 4 additions & 1 deletion tools/verify_fixture_checksums.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ def main() -> int:
actual = {
path
for path in FIXTURES.iterdir()
if path.is_file() and path.name not in {"README.md", "SHA256SUMS"}
# Dotfiles such as a Finder .DS_Store are not fixtures.
if path.is_file()
and not path.name.startswith(".")
and path.name not in {"README.md", "SHA256SUMS"}
}
if actual != set(expected):
missing = sorted(str(path.relative_to(ROOT)) for path in actual - set(expected))
Expand Down
Loading