diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecfa45..dcfcdef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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` diff --git a/src/api/client.cpp b/src/api/client.cpp index 79195b7..e1e3d5e 100644 --- a/src/api/client.cpp +++ b/src/api/client.cpp @@ -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 [[nodiscard]] Result paged_into(const LayerQuery& query, Payload payload, std::vector Payload::*items, diff --git a/src/core/error.cpp b/src/core/error.cpp index d5afd74..93a0628 100644 --- a/src/core/error.cpp +++ b/src/core/error.cpp @@ -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; diff --git a/tests/test_client.cpp b/tests/test_client.cpp index 151b296..273fec6 100644 --- a/tests/test_client.cpp +++ b/tests/test_client.cpp @@ -8,6 +8,7 @@ #include "support/fixtures.hpp" #include +#include #include #include #include @@ -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); @@ -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 transport = std::make_shared(); + transport->responses = { + {200, + std::format(R"({{"error":{{"code":{},"message":"Busy","details":[]}}}})", code), + {}}, + }; + const ArcGISClient client{transport}; + + const Result 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 diff --git a/tools/verify_fixture_checksums.py b/tools/verify_fixture_checksums.py index 2a1144a..02035d8 100755 --- a/tools/verify_fixture_checksums.py +++ b/tools/verify_fixture_checksums.py @@ -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))