From cfe8f6f99dae36db26845e588d1c8ee43b87f8e0 Mon Sep 17 00:00:00 2001 From: yluoc Date: Wed, 7 Oct 2026 11:11:25 -0400 Subject: [PATCH 1/5] feat(errors): parse Retry-After and Poly-RateLimit headers --- CMakeLists.txt | 1 + cmake/PolymarketTests.cmake | 5 + include/polymarket/rate_limit.hpp | 41 +++++++ include/polymarket/sdk_error.hpp | 4 + src/rate_limit.cpp | 197 ++++++++++++++++++++++++++++++ src/rate_limit_internal.hpp | 45 +++++++ src/sdk_error.cpp | 4 + tests/test_rate_limit.cpp | 192 +++++++++++++++++++++++++++++ 8 files changed, 489 insertions(+) create mode 100644 include/polymarket/rate_limit.hpp create mode 100644 src/rate_limit.cpp create mode 100644 src/rate_limit_internal.hpp create mode 100644 tests/test_rate_limit.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 68994c5..32080d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -165,6 +165,7 @@ set(POLYMARKET_CLIENT_SOURCES src/order_signer_domain.cpp src/order_signer_v2.cpp src/sdk_error.cpp + src/rate_limit.cpp src/clob_account.cpp src/clob_activity.cpp src/clob_activity_pagination.cpp diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index 59fd8fd..32f9cb8 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -158,6 +158,11 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_sdk_error PRIVATE polymarket::client) add_test(NAME test_sdk_error COMMAND test_sdk_error) + add_executable(test_rate_limit tests/test_rate_limit.cpp) + target_include_directories(test_rate_limit PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_rate_limit PRIVATE polymarket::client) + add_test(NAME test_rate_limit COMMAND test_rate_limit) + add_executable(test_websocket_resilience tests/test_websocket_resilience.cpp) target_include_directories(test_websocket_resilience PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(test_websocket_resilience PRIVATE polymarket::client) diff --git a/include/polymarket/rate_limit.hpp b/include/polymarket/rate_limit.hpp new file mode 100644 index 0000000..9c75e5d --- /dev/null +++ b/include/polymarket/rate_limit.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include + +namespace polymarket +{ + // Per-signer rate-limit state from the Poly-RateLimit-* response headers. + // Each field is set only when its header is present and well formed. + struct RateLimitUpdate + { + enum class Bucket : std::uint8_t + { + None, + Order, + Cancel + }; + + Bucket bucket{Bucket::None}; + // Tokens left in the bucket; may be negative. Zero alone does not mean + // the bucket is exhausted, so do not back off on it. + std::optional remaining; + std::optional reset; // Unix seconds when the wait period ends + std::optional tier; + // True when the limiter runs in warning mode and would have rejected + // this request under enforcement. + bool warning{false}; + }; + + // Retries for HTTP 429 on read requests. Each retry waits exactly the + // server's Retry-After delay (1 s when absent); a longer requested delay + // than max_delay returns the error instead. + struct RateLimitRetry + { + int retries{2}; // after the first attempt + std::chrono::milliseconds max_delay{5000}; + }; + +} // namespace polymarket diff --git a/include/polymarket/sdk_error.hpp b/include/polymarket/sdk_error.hpp index cf1ea80..37a17a5 100644 --- a/include/polymarket/sdk_error.hpp +++ b/include/polymarket/sdk_error.hpp @@ -1,6 +1,7 @@ #pragma once #include "polymarket/http_client.hpp" +#include "polymarket/rate_limit.hpp" #include #include #include @@ -29,6 +30,9 @@ namespace polymarket std::string response_body_excerpt; std::string request_id; bool retryable{false}; + // Server-suggested wait from Retry-After or a retry_after_seconds body field. + std::optional retry_after_seconds; + std::optional rate_limit; }; template diff --git a/src/rate_limit.cpp b/src/rate_limit.cpp new file mode 100644 index 0000000..8206620 --- /dev/null +++ b/src/rate_limit.cpp @@ -0,0 +1,197 @@ +#include "rate_limit_internal.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + namespace + { + const std::string *header(const HttpResponse &response, const char *lowercase_key) + { + const auto it = response.headers.find(lowercase_key); + return it == response.headers.end() ? nullptr : &it->second; + } + + bool all_digits(std::string_view text) + { + return !text.empty() && std::all_of(text.begin(), text.end(), [](unsigned char c) + { return std::isdigit(c) != 0; }); + } + + // Non-negative decimal such as "2" or "1.5". + std::optional parse_delay_seconds(std::string_view text) + { + const auto dot = text.find('.'); + const auto whole = text.substr(0, dot); + const auto fraction = + dot == std::string_view::npos ? std::string_view{} : text.substr(dot + 1); + if (!all_digits(whole) || (dot != std::string_view::npos && !all_digits(fraction)) || + whole.size() > 9) + return std::nullopt; + double value = 0.0; + for (const char c : whole) + value = value * 10 + (c - '0'); + double scale = 0.1; + for (const char c : fraction) + { + value += (c - '0') * scale; + scale /= 10; + } + return value; + } + + std::optional parse_integer(std::string_view text, bool allow_negative) + { + const bool negative = allow_negative && !text.empty() && text.front() == '-'; + const auto digits = negative ? text.substr(1) : text; + if (!all_digits(digits) || digits.size() > 18) return std::nullopt; + long long value = 0; + for (const char c : digits) + value = value * 10 + (c - '0'); + return negative ? -value : value; + } + + // Days since 1970-01-01 for a proleptic Gregorian date. + std::int64_t days_from_civil(std::int64_t year, unsigned month, unsigned day) + { + year -= month <= 2; + const std::int64_t era = (year >= 0 ? year : year - 399) / 400; + const auto year_of_era = static_cast(year - era * 400); + const unsigned day_of_year = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1; + const unsigned day_of_era = + year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + return era * 146097 + static_cast(day_of_era) - 719468; + } + + int month_number(const char *name) + { + static constexpr std::array months = { + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}; + for (std::size_t i = 0; i < months.size(); ++i) + if (std::string_view(name) == months[i]) return static_cast(i) + 1; + return 0; + } + + // RFC 9110 section 5.6.7: IMF-fixdate plus the obsolete RFC 850 and + // asctime forms, all read as UTC. + std::optional parse_http_date(const std::string &text) + { + char weekday[16] = {}; + char month[4] = {}; + int day = 0; + int year = 0; + int hour = 0; + int minute = 0; + int second = 0; + int consumed = 0; + bool parsed = + std::sscanf(text.c_str(), "%3s, %2d %3s %4d %2d:%2d:%2d GMT%n", weekday, &day, + month, &year, &hour, &minute, &second, &consumed) == 7 && + static_cast(consumed) == text.size(); + if (!parsed) + { + parsed = + std::sscanf(text.c_str(), "%15[A-Za-z], %2d-%3s-%2d %2d:%2d:%2d GMT%n", weekday, + &day, month, &year, &hour, &minute, &second, &consumed) == 7 && + static_cast(consumed) == text.size(); + if (parsed) year += year < 70 ? 2000 : 1900; + } + if (!parsed) + parsed = std::sscanf(text.c_str(), "%3s %3s %2d %2d:%2d:%2d %4d%n", weekday, month, + &day, &hour, &minute, &second, &year, &consumed) == 7 && + static_cast(consumed) == text.size(); + const int month_index = month_number(month); + if (!parsed || month_index == 0 || day < 1 || day > 31 || hour > 23 || minute > 59 || + second > 60) + return std::nullopt; + return days_from_civil(year, static_cast(month_index), + static_cast(day)) * + 86400 + + hour * 3600 + minute * 60 + second; + } + + std::optional retry_after_from_body(const std::string &body) + { + if (body.empty()) return std::nullopt; + try + { + const auto parsed = nlohmann::json::parse(body); + if (parsed.is_object() && parsed.contains("retry_after_seconds")) + { + const auto &value = parsed["retry_after_seconds"]; + if (value.is_number() && value.get() >= 0) return value.get(); + } + } + catch (const nlohmann::json::exception &) + { + } + return std::nullopt; + } + } // namespace + + std::optional parse_retry_after_seconds(const HttpResponse &response, + std::int64_t now_unix_seconds) + { + if (const auto *value = header(response, "retry-after")) + { + if (const auto seconds = parse_delay_seconds(*value)) return seconds; + if (const auto date = parse_http_date(*value)) + return static_cast(std::max(0, *date - now_unix_seconds)); + } + return retry_after_from_body(response.body); + } + + std::optional parse_retry_after_seconds(const HttpResponse &response) + { + const auto now = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); + return parse_retry_after_seconds(response, now); + } + + std::optional parse_rate_limit_update(const HttpResponse &response, + RateLimitUpdate::Bucket bucket) + { + RateLimitUpdate update; + update.bucket = bucket; + if (const auto *value = header(response, "poly-ratelimit-remaining")) + update.remaining = parse_integer(*value, true); + if (const auto *value = header(response, "poly-ratelimit-reset")) + update.reset = parse_integer(*value, false); + if (const auto *value = header(response, "poly-ratelimit-tier"); value && !value->empty()) + update.tier = *value; + if (const auto *value = header(response, "poly-ratelimit-warning")) + { + std::string lowered = *value; + std::transform(lowered.begin(), lowered.end(), lowered.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + update.warning = lowered == "true"; + } + if (!update.remaining && !update.reset && !update.tier && !update.warning) + return std::nullopt; + return update; + } + + std::chrono::milliseconds rate_limit_delay(const HttpResponse &response) + { + const auto seconds = parse_retry_after_seconds(response).value_or(1.0); + return std::chrono::milliseconds(static_cast(std::ceil(seconds * 1000))); + } + + void validate_rate_limit_retry(const RateLimitRetry &policy) + { + if (policy.retries < 0) + throw std::invalid_argument("RateLimitRetry.retries must be non-negative"); + if (policy.max_delay < std::chrono::milliseconds::zero()) + throw std::invalid_argument("RateLimitRetry.max_delay must be non-negative"); + } + +} // namespace polymarket::detail diff --git a/src/rate_limit_internal.hpp b/src/rate_limit_internal.hpp new file mode 100644 index 0000000..3ce1664 --- /dev/null +++ b/src/rate_limit_internal.hpp @@ -0,0 +1,45 @@ +#pragma once + +#include "polymarket/http_client.hpp" +#include "polymarket/rate_limit.hpp" +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + // Retry-After as seconds or an HTTP date (relative to now_unix_seconds), + // else a retry_after_seconds body field. Never negative; nullopt when absent + // or malformed. + std::optional parse_retry_after_seconds(const HttpResponse &response, + std::int64_t now_unix_seconds); + std::optional parse_retry_after_seconds(const HttpResponse &response); + + // Nullopt when the response carries none of the Poly-RateLimit-* headers. + std::optional parse_rate_limit_update(const HttpResponse &response, + RateLimitUpdate::Bucket bucket); + + // Delay before retrying a 429: the server's Retry-After, or 1 s. + std::chrono::milliseconds rate_limit_delay(const HttpResponse &response); + + void validate_rate_limit_retry(const RateLimitRetry &policy); + + // Runs attempt() and retries it while the response is HTTP 429, sleeping + // the server-requested delay between attempts. attempt() must rebuild any + // signed headers so each retry carries a fresh timestamp. + template + HttpResponse retry_rate_limited(const std::optional &policy, Attempt &&attempt) + { + for (int retry = 0;; ++retry) + { + auto response = attempt(); + if (response.status_code != 429 || !policy || retry >= policy->retries) return response; + const auto delay = rate_limit_delay(response); + if (delay > policy->max_delay) return response; + std::this_thread::sleep_for(delay); + } + } + +} // namespace polymarket::detail diff --git a/src/sdk_error.cpp b/src/sdk_error.cpp index bfc9518..46a621d 100644 --- a/src/sdk_error.cpp +++ b/src/sdk_error.cpp @@ -1,4 +1,5 @@ #include "polymarket/sdk_error.hpp" +#include "rate_limit_internal.hpp" #include #include #include @@ -110,6 +111,9 @@ namespace polymarket return error; } + error.retry_after_seconds = detail::parse_retry_after_seconds(response); + error.rate_limit = detail::parse_rate_limit_update(response, RateLimitUpdate::Bucket::None); + const auto api_message = parse_api_error_message(response.body); error.message = api_message.empty() ? "API request failed" : api_message; diff --git a/tests/test_rate_limit.cpp b/tests/test_rate_limit.cpp new file mode 100644 index 0000000..b75c11d --- /dev/null +++ b/tests/test_rate_limit.cpp @@ -0,0 +1,192 @@ +#include "check_support.hpp" +#include "polymarket/sdk_error.hpp" +#include "rate_limit_internal.hpp" + +#include +#include + +namespace +{ + using check_support::check; + using polymarket::HttpResponse; + using polymarket::RateLimitRetry; + using polymarket::RateLimitUpdate; + using namespace polymarket::detail; + + // RFC 9110's example date, Sun, 06 Nov 1994 08:49:37 GMT. + constexpr std::int64_t example_date = 784111777; + + HttpResponse response_with(std::map headers, std::string body = "", + long status = 429) + { + HttpResponse response; + response.status_code = status; + response.headers = std::move(headers); + response.body = std::move(body); + return response; + } + + std::optional retry_after(const std::string &value, + std::int64_t now = example_date - 10) + { + return parse_retry_after_seconds(response_with({{"retry-after", value}}), now); + } + + void test_retry_after_seconds() + { + check(retry_after("2") == 2.0, "integer Retry-After"); + check(retry_after("1.5") == 1.5, "decimal Retry-After"); + check(retry_after("0") == 0.0, "zero Retry-After"); + check(!retry_after("-1") && !retry_after("") && !retry_after("soon") && !retry_after("1."), + "malformed Retry-After must be ignored"); + } + + void test_retry_after_http_dates() + { + check(retry_after("Sun, 06 Nov 1994 08:49:37 GMT") == 10.0, "IMF-fixdate Retry-After"); + check(retry_after("Sunday, 06-Nov-94 08:49:37 GMT") == 10.0, "RFC 850 Retry-After"); + check(retry_after("Sun Nov 6 08:49:37 1994") == 10.0, "asctime Retry-After"); + check(retry_after("Sun, 06 Nov 1994 08:49:37 GMT", example_date + 60) == 0.0, + "a past HTTP date must clamp to zero"); + check(!retry_after("Sun, 06 Foo 1994 08:49:37 GMT") && + !retry_after("Sun, 06 Nov 1994 08:49:37 UTC"), + "malformed HTTP dates must be ignored"); + } + + void test_retry_after_body_field() + { + check(parse_retry_after_seconds(response_with({}, R"({"retry_after_seconds":3})"), 0) == + 3.0, + "retry_after_seconds body field"); + check(parse_retry_after_seconds( + response_with({{"retry-after", "1"}}, R"({"retry_after_seconds":3})"), 0) == 1.0, + "the Retry-After header must win over the body field"); + check(!parse_retry_after_seconds(response_with({}, R"({"retry_after_seconds":-3})"), 0) && + !parse_retry_after_seconds(response_with({}, "not json"), 0) && + !parse_retry_after_seconds(response_with({}), 0), + "absent or invalid body fields must be ignored"); + } + + void test_rate_limit_update_headers() + { + const auto update = + parse_rate_limit_update(response_with({{"poly-ratelimit-remaining", "-5"}, + {"poly-ratelimit-reset", "1700000000"}, + {"poly-ratelimit-tier", "standard"}, + {"poly-ratelimit-warning", "TRUE"}}), + RateLimitUpdate::Bucket::Order); + check(update && update->bucket == RateLimitUpdate::Bucket::Order && + update->remaining == -5 && update->reset == 1700000000 && + update->tier == "standard" && update->warning, + "all Poly-RateLimit headers must parse"); + + const auto partial = + parse_rate_limit_update(response_with({{"poly-ratelimit-remaining", "1.5"}, + {"poly-ratelimit-reset", "-1"}, + {"poly-ratelimit-tier", "gold"}}), + RateLimitUpdate::Bucket::None); + check(partial && !partial->remaining && !partial->reset && partial->tier == "gold" && + !partial->warning, + "malformed values must be dropped independently"); + + check(!parse_rate_limit_update(response_with({{"poly-ratelimit-warning", "false"}}), + RateLimitUpdate::Bucket::None) && + !parse_rate_limit_update(response_with({}), RateLimitUpdate::Bucket::None), + "responses without rate-limit state must yield nullopt"); + } + + void test_rate_limit_delay() + { + using std::chrono::milliseconds; + check(rate_limit_delay(response_with({})) == milliseconds(1000), + "missing Retry-After waits 1 s"); + check(rate_limit_delay(response_with({{"retry-after", "0.25"}})) == milliseconds(250), + "Retry-After converts to milliseconds"); + } + + void test_retry_loop() + { + int attempts = 0; + const auto limited_then_ok = [&attempts] + { + ++attempts; + return attempts < 3 ? response_with({{"retry-after", "0"}}) + : response_with({}, "{}", 200); + }; + check(retry_rate_limited(RateLimitRetry{}, limited_then_ok).status_code == 200 && + attempts == 3, + "two 429s must be retried under the default policy"); + + attempts = 0; + check( + retry_rate_limited(RateLimitRetry{1, std::chrono::milliseconds(5000)}, limited_then_ok) + .status_code == 429 && + attempts == 2, + "retries must stop at the configured count"); + + attempts = 0; + check(retry_rate_limited(std::nullopt, limited_then_ok).status_code == 429 && attempts == 1, + "a disabled policy must not retry"); + + attempts = 0; + const auto long_wait = [&attempts] + { + ++attempts; + return response_with({{"retry-after", "10"}}); + }; + check(retry_rate_limited(RateLimitRetry{}, long_wait).status_code == 429 && attempts == 1, + "a delay above max_delay must return the 429 without waiting"); + + attempts = 0; + const auto server_error = [&attempts] + { + ++attempts; + return response_with({{"retry-after", "0"}}, "", 503); + }; + check(retry_rate_limited(RateLimitRetry{}, server_error).status_code == 503 && + attempts == 1, + "only HTTP 429 is retried"); + } + + void test_policy_validation() + { + check_support::expect_throws( + "negative retries", + [] { validate_rate_limit_retry({-1, std::chrono::milliseconds(0)}); }); + check_support::expect_throws( + "negative max_delay", + [] { validate_rate_limit_retry({1, std::chrono::milliseconds(-1)}); }); + validate_rate_limit_retry({0, std::chrono::milliseconds(0)}); + } + + void test_sdk_error_fields() + { + const auto error = polymarket::make_sdk_error( + response_with({{"retry-after", "2"}, {"poly-ratelimit-remaining", "0"}}, + R"({"error":"slow down"})"), + "/book"); + check(error.code == polymarket::SdkErrorCode::RateLimit && + error.retry_after_seconds == 2.0 && error.rate_limit && + error.rate_limit->remaining == 0, + "make_sdk_error must carry Retry-After and rate-limit state"); + + HttpResponse transport_failure; + transport_failure.error = "connection refused"; + const auto transport = polymarket::make_sdk_error(transport_failure, "/book"); + check(!transport.retry_after_seconds && !transport.rate_limit, + "transport failures carry no rate-limit fields"); + } +} // namespace + +int main() +{ + test_retry_after_seconds(); + test_retry_after_http_dates(); + test_retry_after_body_field(); + test_rate_limit_update_headers(); + test_rate_limit_delay(); + test_retry_loop(); + test_policy_validation(); + test_sdk_error_fields(); + return check_support::finish("test_rate_limit"); +} From 53b826ce5a3dabaa4f008e1c59c2fe9ea072c817 Mon Sep 17 00:00:00 2001 From: yluoc Date: Wed, 7 Oct 2026 11:16:58 -0400 Subject: [PATCH 2/5] feat(clob): report rate-limit updates to a listener --- CMakeLists.txt | 1 + cmake/PolymarketTests.cmake | 4 ++ include/polymarket/clob_client.hpp | 11 +++ src/clob_client_test_fixture.hpp | 18 ++++- src/clob_order_submission.cpp | 6 ++ src/clob_rate_limit.cpp | 37 ++++++++++ tests/test_clob_rate_limit.cpp | 109 +++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 src/clob_rate_limit.cpp create mode 100644 tests/test_clob_rate_limit.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 32080d6..8ac3f72 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -171,6 +171,7 @@ set(POLYMARKET_CLIENT_SOURCES src/clob_activity_pagination.cpp src/clob_books.cpp src/clob_client.cpp + src/clob_rate_limit.cpp src/clob_market_parsing.cpp src/clob_market_metadata.cpp src/clob_mutation_parsing.cpp diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index 32f9cb8..3337772 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -163,6 +163,10 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_rate_limit PRIVATE polymarket::client) add_test(NAME test_rate_limit COMMAND test_rate_limit) + add_executable(test_clob_rate_limit tests/test_clob_rate_limit.cpp) + target_link_libraries(test_clob_rate_limit PRIVATE polymarket::client) + add_test(NAME test_clob_rate_limit COMMAND test_clob_rate_limit) + add_executable(test_websocket_resilience tests/test_websocket_resilience.cpp) target_include_directories(test_websocket_resilience PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(test_websocket_resilience PRIVATE polymarket::client) diff --git a/include/polymarket/clob_client.hpp b/include/polymarket/clob_client.hpp index 0d8c527..60a7d2a 100644 --- a/include/polymarket/clob_client.hpp +++ b/include/polymarket/clob_client.hpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -235,6 +236,11 @@ namespace polymarket // TCP keepalive probe interval void set_keepalive_interval(long seconds) { http_.set_keepalive_interval(seconds); data_http_.set_keepalive_interval(seconds); } + // Receives Poly-RateLimit-* state reported by order and cancel responses, + // on the thread that made the request. Listener exceptions are ignored; + // an empty function removes the listener. + void set_rate_limit_listener(std::function listener); + // ============================================================ // CONNECTION WARMING (for low-latency trading) // ============================================================ @@ -308,6 +314,11 @@ namespace polymarket std::map> tick_size_cache_; std::map> neg_risk_cache_; + using RateLimitListener = std::function; + mutable std::mutex rate_limit_mutex_; + std::shared_ptr rate_limit_listener_; + void notify_rate_limit(const HttpResponse &response, RateLimitUpdate::Bucket bucket) const; + // Helper methods std::map get_l2_headers(const std::string &method, const std::string &path, diff --git a/src/clob_client_test_fixture.hpp b/src/clob_client_test_fixture.hpp index 24a6fdb..80a5148 100644 --- a/src/clob_client_test_fixture.hpp +++ b/src/clob_client_test_fixture.hpp @@ -37,6 +37,7 @@ namespace clob_test std::string body; int delay_ms{0}; std::function body_factory; + std::map headers; }; class LocalServer @@ -77,6 +78,12 @@ namespace clob_test responses_.push_back({status, std::move(body), delay_ms, {}}); } + void enqueue(std::string body, int status, std::map headers) + { + std::lock_guard lock(mutex_); + responses_.push_back({status, std::move(body), 0, {}, std::move(headers)}); + } + void enqueue(std::function body_factory, int status = 200, int delay_ms = 0) { @@ -169,9 +176,14 @@ namespace clob_test if (response.delay_ms > 0) std::this_thread::sleep_for(std::chrono::milliseconds(response.delay_ms)); const std::string reason = response.status == 200 ? "OK" : "ERROR"; - const std::string wire = "HTTP/1.1 " + std::to_string(response.status) + " " + reason + - "\r\nContent-Type: application/json\r\nContent-Length: " + - std::to_string(response.body.size()) + "\r\nConnection: close\r\n\r\n" + response.body; + std::string extra_headers; + for (const auto &[name, value] : response.headers) + extra_headers += name + ": " + value + "\r\n"; + const std::string wire = + "HTTP/1.1 " + std::to_string(response.status) + " " + reason + + "\r\nContent-Type: application/json\r\nContent-Length: " + + std::to_string(response.body.size()) + "\r\nConnection: close\r\n" + + extra_headers + "\r\n" + response.body; send(client, wire.data(), wire.size(), 0); close(client); } diff --git a/src/clob_order_submission.cpp b/src/clob_order_submission.cpp index ad5e44c..f21f98d 100644 --- a/src/clob_order_submission.cpp +++ b/src/clob_order_submission.cpp @@ -120,6 +120,7 @@ namespace polymarket const std::string body_str = body.dump(); auto headers = get_l2_headers("POST", "/order", body_str); auto response = http_.post("/order", body_str, headers); + notify_rate_limit(response, RateLimitUpdate::Bucket::Order); if (!response.ok()) { @@ -163,6 +164,7 @@ namespace polymarket const std::string body_str = body.dump(); auto headers = get_l2_headers("POST", "/orders", body_str); auto response = http_.post("/orders", body_str, headers); + notify_rate_limit(response, RateLimitUpdate::Bucket::Order); if (!response.ok()) { @@ -236,6 +238,7 @@ namespace polymarket auto headers = get_l2_headers("DELETE", "/order", body_str); auto response = http_.del("/order", body_str, headers); + notify_rate_limit(response, RateLimitUpdate::Bucket::Cancel); if (!response.ok()) { return Result::failure(make_sdk_error(response, "/order")); @@ -265,6 +268,7 @@ namespace polymarket auto headers = get_l2_headers("DELETE", "/orders", body_str); auto response = http_.del("/orders", body_str, headers); + notify_rate_limit(response, RateLimitUpdate::Bucket::Cancel); if (!response.ok()) return false; try { @@ -281,6 +285,7 @@ namespace polymarket { auto headers = get_l2_headers("DELETE", "/cancel-all", ""); auto response = http_.del("/cancel-all", "", headers); + notify_rate_limit(response, RateLimitUpdate::Bucket::Cancel); return confirms_bulk_cancellation(response); } @@ -293,6 +298,7 @@ namespace polymarket auto headers = get_l2_headers("DELETE", "/cancel-market-orders", body_str); auto response = http_.del("/cancel-market-orders", body_str, headers); + notify_rate_limit(response, RateLimitUpdate::Bucket::Cancel); return confirms_bulk_cancellation(response); } } diff --git a/src/clob_rate_limit.cpp b/src/clob_rate_limit.cpp new file mode 100644 index 0000000..353c552 --- /dev/null +++ b/src/clob_rate_limit.cpp @@ -0,0 +1,37 @@ +#include "polymarket/clob_client.hpp" +#include "rate_limit_internal.hpp" + +#include + +namespace polymarket +{ + void ClobClient::set_rate_limit_listener(std::function listener) + { + auto shared = + listener ? std::make_shared(std::move(listener)) : nullptr; + std::lock_guard lock(rate_limit_mutex_); + rate_limit_listener_ = std::move(shared); + } + + void ClobClient::notify_rate_limit(const HttpResponse &response, + RateLimitUpdate::Bucket bucket) const + { + std::shared_ptr listener; + { + std::lock_guard lock(rate_limit_mutex_); + listener = rate_limit_listener_; + } + if (!listener) return; + const auto update = detail::parse_rate_limit_update(response, bucket); + if (!update) return; + try + { + (*listener)(*update); + } + catch (...) + { + // A failing listener must not change the outcome of the request. + } + } + +} // namespace polymarket diff --git a/tests/test_clob_rate_limit.cpp b/tests/test_clob_rate_limit.cpp new file mode 100644 index 0000000..01c6c19 --- /dev/null +++ b/tests/test_clob_rate_limit.cpp @@ -0,0 +1,109 @@ +#include "../src/clob_client_test_fixture.hpp" + +#include + +namespace clob_test +{ + namespace + { + const std::map rate_limit_headers = { + {"Poly-RateLimit-Remaining", "12"}, + {"Poly-RateLimit-Reset", "1700000000"}, + {"Poly-RateLimit-Tier", "standard"}, + {"Poly-RateLimit-Warning", "true"}, + }; + + constexpr const char *accepted_order = + R"({"success":true,"orderID":"order-1","status":"live"})"; + constexpr const char *confirmed_cancel = R"({"canceled":["order-1"],"not_canceled":{}})"; + + SignedOrder test_order() + { + SignedOrder order; + order.salt = "1"; + return order; + } + } // namespace + + bool test_listener_receives_order_and_cancel_buckets() + { + LocalServer server; + auto client = authenticated_client(server.url()); + std::vector updates; + client.set_rate_limit_listener([&updates](const RateLimitUpdate &update) + { updates.push_back(update); }); + + server.enqueue(accepted_order, 200, rate_limit_headers); + const auto posted = client.post_order_result(test_order(), OrderType::GTC); + server.enqueue(confirmed_cancel, 200, {{"Poly-RateLimit-Remaining", "-3"}}); + const auto cancelled = client.cancel_order_result("order-1"); + server.enqueue(accepted_order); + (void)client.post_order_result(test_order(), OrderType::GTC); + + return check(posted && cancelled, "order and cancel requests must still succeed") && + check(updates.size() == 2, + "only responses with rate-limit headers notify the listener") && + check(updates[0].bucket == RateLimitUpdate::Bucket::Order && + updates[0].remaining == 12 && updates[0].reset == 1700000000 && + updates[0].tier == "standard" && updates[0].warning, + "order responses must report the order bucket and every header") && + check(updates[1].bucket == RateLimitUpdate::Bucket::Cancel && + updates[1].remaining == -3 && !updates[1].warning, + "cancel responses must report the cancel bucket"); + } + + bool test_rate_limited_order_is_not_retried() + { + LocalServer server; + auto client = authenticated_client(server.url()); + int notifications = 0; + client.set_rate_limit_listener([¬ifications](const RateLimitUpdate &) + { ++notifications; }); + + auto headers = rate_limit_headers; + headers["Retry-After"] = "0"; + server.enqueue(R"({"error":"too many requests"})", 429, headers); + const auto result = client.post_order_result(test_order(), OrderType::GTC); + + return check(!result && result.error().code == SdkErrorCode::RateLimit && + result.error().retry_after_seconds == 0.0 && result.error().rate_limit && + result.error().rate_limit->remaining == 12, + "a rate-limited order must return RateLimit with Retry-After and state") && + check(server.requests().size() == 1, "orders must never be retried automatically") && + check(notifications == 1, "a rate-limited response must still notify the listener"); + } + + bool test_throwing_listener_and_removal() + { + LocalServer server; + auto client = authenticated_client(server.url()); + int calls = 0; + client.set_rate_limit_listener( + [&calls](const RateLimitUpdate &) + { + ++calls; + throw std::runtime_error("listener failure"); + }); + + server.enqueue(accepted_order, 200, rate_limit_headers); + const auto posted = client.post_order_result(test_order(), OrderType::GTC); + client.set_rate_limit_listener({}); + server.enqueue(confirmed_cancel, 200, rate_limit_headers); + (void)client.cancel_all(); + + return check(posted && calls == 1, + "a throwing listener must not change the request result") && + check(calls == 1, "an empty listener must stop notifications"); + } +} // namespace clob_test + +int main() +{ + using namespace clob_test; + polymarket::http_global_init(); + const bool ok = test_listener_receives_order_and_cancel_buckets() && + test_rate_limited_order_is_not_retried() && + test_throwing_listener_and_removal(); + polymarket::http_global_cleanup(); + return ok ? 0 : 1; +} From bdf5c8abe3f4c89d7e53cc52c9df5745f8ce5341 Mon Sep 17 00:00:00 2001 From: yluoc Date: Wed, 7 Oct 2026 11:31:40 -0400 Subject: [PATCH 3/5] feat(clob): retry rate-limited reads --- include/polymarket/clob_client.hpp | 10 +++ src/clob_account.cpp | 25 ++++--- src/clob_activity.cpp | 36 +++++---- src/clob_activity_pagination.cpp | 14 ++-- src/clob_books.cpp | 43 ++++++++--- src/clob_client.cpp | 29 +++++--- src/clob_client_internal.hpp | 9 +++ src/clob_market_metadata.cpp | 12 ++- src/clob_positions.cpp | 10 +-- src/clob_rate_limit.cpp | 13 ++++ tests/test_clob_rate_limit.cpp | 115 ++++++++++++++++++++++++++++- 11 files changed, 254 insertions(+), 62 deletions(-) diff --git a/include/polymarket/clob_client.hpp b/include/polymarket/clob_client.hpp index 60a7d2a..65e86f7 100644 --- a/include/polymarket/clob_client.hpp +++ b/include/polymarket/clob_client.hpp @@ -241,6 +241,11 @@ namespace polymarket // an empty function removes the listener. void set_rate_limit_listener(std::function listener); + // Read requests that get HTTP 429 are retried after the server's + // Retry-After delay; orders, cancellations and other writes never are. + // Defaults to RateLimitRetry{}; std::nullopt disables retries. + void set_rate_limit_retry(std::optional policy); + // ============================================================ // CONNECTION WARMING (for low-latency trading) // ============================================================ @@ -317,7 +322,12 @@ namespace polymarket using RateLimitListener = std::function; mutable std::mutex rate_limit_mutex_; std::shared_ptr rate_limit_listener_; + std::optional rate_limit_retry_{RateLimitRetry{}}; void notify_rate_limit(const HttpResponse &response, RateLimitUpdate::Bucket bucket) const; + std::optional rate_limit_retry() const; + // Runs a read request under the retry policy; attempt() must rebuild + // signed headers. Defined in clob_client_internal.hpp. + template HttpResponse read(Attempt &&attempt) const; // Helper methods std::map get_l2_headers(const std::string &method, diff --git a/src/clob_account.cpp b/src/clob_account.cpp index 7f2ea90..ebb63bf 100644 --- a/src/clob_account.cpp +++ b/src/clob_account.cpp @@ -61,8 +61,9 @@ namespace polymarket { std::vector result; - auto headers = get_l2_headers("GET", "/auth/api-keys", ""); - auto response = http_.get("/auth/api-keys", headers); + auto response = read( + [&] + { return http_.get("/auth/api-keys", get_l2_headers("GET", "/auth/api-keys", "")); }); if (!response.ok()) return result; @@ -106,8 +107,8 @@ namespace polymarket return Result>::failure(make_auth_error("Client not authenticated", endpoint)); } - auto headers = get_l2_headers("GET", endpoint, ""); - auto response = http_.get(endpoint, headers); + auto response = + read([&] { return http_.get(endpoint, get_l2_headers("GET", endpoint, "")); }); if (!response.ok()) { @@ -158,8 +159,8 @@ namespace polymarket path += market.empty() ? "?" : "?market=" + percent_encode_query_value(market) + "&"; path += "next_cursor=" + percent_encode_query_value(pagination.cursor()); - auto headers = get_l2_headers("GET", endpoint, ""); - auto response = http_.get(path, headers); + auto response = + read([&] { return http_.get(path, get_l2_headers("GET", endpoint, "")); }); if (!response.ok()) { @@ -201,8 +202,8 @@ namespace polymarket const std::string path = std::string(endpoint) + "?next_cursor=" + percent_encode_query_value(pagination.cursor()); - auto headers = get_l2_headers("GET", endpoint, ""); - auto response = http_.get(path, headers); + auto response = + read([&] { return http_.get(path, get_l2_headers("GET", endpoint, "")); }); if (!response.ok()) return {}; @@ -230,8 +231,8 @@ namespace polymarket "&signature_type=" + std::to_string(static_cast(sig_type_)); if (!token_id.empty()) path += "&token_id=" + percent_encode_query_value(token_id); - auto headers = get_l2_headers("GET", request_path, ""); - auto response = http_.get(path, headers); + auto response = + read([&] { return http_.get(path, get_l2_headers("GET", request_path, "")); }); if (!response.ok()) return std::nullopt; @@ -269,8 +270,8 @@ namespace polymarket "&signature_type=" + std::to_string(static_cast(sig_type_)); if (!token_id.empty()) path += "&token_id=" + percent_encode_query_value(token_id); - auto headers = get_l2_headers("GET", request_path, ""); - auto response = http_.get(path, headers); + auto response = + read([&] { return http_.get(path, get_l2_headers("GET", request_path, "")); }); return response.ok(); } } diff --git a/src/clob_activity.cpp b/src/clob_activity.cpp index 5fa39ec..134f00e 100644 --- a/src/clob_activity.cpp +++ b/src/clob_activity.cpp @@ -15,10 +15,13 @@ namespace polymarket std::optional ClobClient::is_order_scoring(const std::string &order_id) { constexpr const char *endpoint = "/order-scoring"; - auto headers = get_l2_headers("GET", endpoint, ""); - auto response = http_.get(std::string(endpoint) + - "?order_id=" + percent_encode_query_value(order_id), - headers); + auto response = read( + [&] + { + return http_.get(std::string(endpoint) + + "?order_id=" + percent_encode_query_value(order_id), + get_l2_headers("GET", endpoint, "")); + }); if (!response.ok()) return std::nullopt; @@ -52,8 +55,12 @@ namespace polymarket return results; const json body = order_ids; std::string body_str = body.dump(); - auto headers = get_l2_headers("POST", "/orders-scoring", body_str); - auto response = http_.post("/orders-scoring", body_str, headers); + auto response = read( + [&] + { + return http_.post("/orders-scoring", body_str, + get_l2_headers("POST", "/orders-scoring", body_str)); + }); if (!response.ok()) return results; @@ -92,10 +99,14 @@ namespace polymarket { std::vector result; - auto headers = get_l2_headers("GET", "/notifications", ""); - auto response = http_.get("/notifications?signature_type=" + - percent_encode_query_value(std::to_string(static_cast(sig_type_))), - headers); + auto response = read( + [&] + { + return http_.get( + "/notifications?signature_type=" + + percent_encode_query_value(std::to_string(static_cast(sig_type_))), + get_l2_headers("GET", "/notifications", "")); + }); if (!response.ok()) return result; @@ -161,10 +172,9 @@ namespace polymarket std::vector ClobClient::get_total_earnings_for_user_for_day_all(const std::string &date) { constexpr const char *endpoint = "/rewards/user/total"; - auto headers = get_l2_headers("GET", endpoint, ""); const std::string path = std::string(endpoint) + "?date=" + percent_encode_query_value(date) + "&signature_type=" + std::to_string(static_cast(sig_type_)); - auto response = http_.get(path, headers); + auto response = read([&] { return http_.get(path, get_l2_headers("GET", endpoint, "")); }); if (!response.ok()) return {}; std::vector result; @@ -191,7 +201,7 @@ namespace polymarket if (token_id.empty()) return std::nullopt; const std::string path = "/fee-rate?token_id=" + percent_encode_query_value(token_id); - auto response = http_.get(path); + auto response = read([&] { return http_.get(path); }); if (!response.ok()) return std::nullopt; diff --git a/src/clob_activity_pagination.cpp b/src/clob_activity_pagination.cpp index 0137f87..b2d85d3 100644 --- a/src/clob_activity_pagination.cpp +++ b/src/clob_activity_pagination.cpp @@ -28,8 +28,12 @@ namespace polymarket return {}; } - auto response = http_.get("/rewards/markets/current?next_cursor=" + - percent_encode_query_value(pagination.cursor())); + auto response = read( + [&] + { + return http_.get("/rewards/markets/current?next_cursor=" + + percent_encode_query_value(pagination.cursor())); + }); if (!response.ok()) return {}; try @@ -77,7 +81,7 @@ namespace polymarket const std::string path = "/rewards/markets/" + condition_id + "?next_cursor=" + percent_encode_query_value(pagination.cursor()); - auto response = http_.get(path); + auto response = read([&] { return http_.get(path); }); if (!response.ok()) return {}; try @@ -133,8 +137,8 @@ namespace polymarket "?date=" + percent_encode_query_value(date) + "&signature_type=" + std::to_string(static_cast(sig_type_)) + "&next_cursor=" + percent_encode_query_value(pagination.cursor()); - auto headers = get_l2_headers("GET", endpoint, ""); - auto response = http_.get(path, headers); + auto response = + read([&] { return http_.get(path, get_l2_headers("GET", endpoint, "")); }); if (!response.ok()) return {}; try diff --git a/src/clob_books.cpp b/src/clob_books.cpp index af9200e..4b0bb9e 100644 --- a/src/clob_books.cpp +++ b/src/clob_books.cpp @@ -14,7 +14,8 @@ namespace polymarket std::optional ClobClient::get_order_book(const std::string &token_id) { if (token_id.empty()) return std::nullopt; - auto response = http_.get("/book?token_id=" + percent_encode_query_value(token_id)); + auto response = read( + [&] { return http_.get("/book?token_id=" + percent_encode_query_value(token_id)); }); if (!response.ok()) return std::nullopt; @@ -32,7 +33,8 @@ namespace polymarket if (remaining.size() != token_ids.size() || remaining.count("") != 0) return result; - auto response = http_.post("/books", book_request_body(token_ids).dump()); + auto response = + read([&] { return http_.post("/books", book_request_body(token_ids).dump()); }); if (!response.ok()) return result; @@ -60,8 +62,12 @@ namespace polymarket std::optional ClobClient::get_price(const std::string &token_id, const std::string &side) { - auto response = http_.get("/price?token_id=" + percent_encode_query_value(token_id) + - "&side=" + percent_encode_query_value(side)); + auto response = read( + [&] + { + return http_.get("/price?token_id=" + percent_encode_query_value(token_id) + + "&side=" + percent_encode_query_value(side)); + }); if (!response.ok()) return std::nullopt; @@ -88,7 +94,11 @@ namespace polymarket } const auto normalized_side = uppercase(side); - auto response = http_.post("/prices", book_request_body(token_ids, normalized_side).dump()); + auto response = read( + [&] + { + return http_.post("/prices", book_request_body(token_ids, normalized_side).dump()); + }); if (!response.ok()) return result; @@ -118,7 +128,12 @@ namespace polymarket std::optional ClobClient::get_last_trade_price(const std::string &token_id) { - auto response = http_.get("/last-trade-price?token_id=" + percent_encode_query_value(token_id)); + auto response = read( + [&] + { + return http_.get("/last-trade-price?token_id=" + + percent_encode_query_value(token_id)); + }); if (!response.ok()) return std::nullopt; @@ -144,7 +159,8 @@ namespace polymarket return result; } - auto response = http_.post("/last-trades-prices", book_request_body(token_ids).dump()); + auto response = read( + [&] { return http_.post("/last-trades-prices", book_request_body(token_ids).dump()); }); if (!response.ok()) return result; @@ -172,7 +188,9 @@ namespace polymarket std::optional ClobClient::get_midpoint(const std::string &token_id) { - auto response = http_.get("/midpoint?token_id=" + percent_encode_query_value(token_id)); + auto response = read( + [&] + { return http_.get("/midpoint?token_id=" + percent_encode_query_value(token_id)); }); if (!response.ok()) return std::nullopt; @@ -198,7 +216,8 @@ namespace polymarket return result; } - auto response = http_.post("/midpoints", book_request_body(token_ids).dump()); + auto response = + read([&] { return http_.post("/midpoints", book_request_body(token_ids).dump()); }); if (!response.ok()) return result; @@ -228,7 +247,8 @@ namespace polymarket std::optional ClobClient::get_spread(const std::string &token_id) { - auto response = http_.get("/spread?token_id=" + percent_encode_query_value(token_id)); + auto response = read( + [&] { return http_.get("/spread?token_id=" + percent_encode_query_value(token_id)); }); if (!response.ok()) return std::nullopt; @@ -254,7 +274,8 @@ namespace polymarket return result; } - auto response = http_.post("/spreads", book_request_body(token_ids).dump()); + auto response = + read([&] { return http_.post("/spreads", book_request_body(token_ids).dump()); }); if (!response.ok()) return result; diff --git a/src/clob_client.cpp b/src/clob_client.cpp index a48bfb3..16ddb89 100644 --- a/src/clob_client.cpp +++ b/src/clob_client.cpp @@ -73,14 +73,14 @@ namespace polymarket data.set_base_url(environment.data_url); } - static ClobMarketPage fetch_market_page( - HttpClient &http, const std::string &endpoint, - const std::string &next_cursor) + template + static ClobMarketPage fetch_market_page(Fetch &&fetch, const std::string &endpoint, + const std::string &next_cursor) { std::string path = endpoint; if (!next_cursor.empty()) path += "?next_cursor=" + percent_encode_query_value(next_cursor); - const auto response = http.get(path); + const auto response = fetch(path); if (!response.ok()) return {}; try { @@ -252,7 +252,7 @@ namespace polymarket std::optional ClobClient::get_server_time() { - auto response = http_.get("/time"); + auto response = read([&] { return http_.get("/time"); }); if (!response.ok()) return std::nullopt; @@ -271,13 +271,15 @@ namespace polymarket ClobMarketPage ClobClient::get_markets(const std::string &next_cursor) { - return fetch_market_page(http_, "/markets", next_cursor); + return fetch_market_page([this](const std::string &path) + { return read([&] { return http_.get(path); }); }, "/markets", + next_cursor); } std::optional ClobClient::get_market(const std::string &condition_id) { if (condition_id.empty()) return std::nullopt; - auto response = http_.get("/markets/" + condition_id); + auto response = read([&] { return http_.get("/markets/" + condition_id); }); if (!response.ok()) return std::nullopt; @@ -291,19 +293,24 @@ namespace polymarket ClobMarketPage ClobClient::get_sampling_markets( const std::string &next_cursor) { - return fetch_market_page(http_, "/sampling-markets", next_cursor); + return fetch_market_page([this](const std::string &path) + { return read([&] { return http_.get(path); }); }, + "/sampling-markets", next_cursor); } ClobMarketPage ClobClient::get_simplified_markets( const std::string &next_cursor) { - return fetch_market_page(http_, "/simplified-markets", next_cursor); + return fetch_market_page([this](const std::string &path) + { return read([&] { return http_.get(path); }); }, + "/simplified-markets", next_cursor); } ClobMarketPage ClobClient::get_sampling_simplified_markets( const std::string &next_cursor) { - return fetch_market_page(http_, "/sampling-simplified-markets", - next_cursor); + return fetch_market_page([this](const std::string &path) + { return read([&] { return http_.get(path); }); }, + "/sampling-simplified-markets", next_cursor); } } diff --git a/src/clob_client_internal.hpp b/src/clob_client_internal.hpp index 3273a45..b42dddc 100644 --- a/src/clob_client_internal.hpp +++ b/src/clob_client_internal.hpp @@ -1,6 +1,7 @@ #pragma once #include "polymarket/clob_client.hpp" +#include "rate_limit_internal.hpp" #include "query_encoding.hpp" #include "rest_numeric.hpp" @@ -79,3 +80,11 @@ namespace polymarket::detail ClobClient::EarningsInfo parse_user_earning_info(const json &item); ClobClient::EarningsInfo parse_total_user_earning_info(const json &item); } + +namespace polymarket +{ + template HttpResponse ClobClient::read(Attempt &&attempt) const + { + return detail::retry_rate_limited(rate_limit_retry(), attempt); + } +} // namespace polymarket diff --git a/src/clob_market_metadata.cpp b/src/clob_market_metadata.cpp index 0601ea5..0b681ed 100644 --- a/src/clob_market_metadata.cpp +++ b/src/clob_market_metadata.cpp @@ -69,7 +69,9 @@ namespace polymarket MetadataFetchCompletion completion(metadata_cache_mutex_, metadata_cache_cv_, metadata_cache_in_flight_, in_flight_key); - auto response = http_.get("/tick-size?token_id=" + percent_encode_query_value(token_id)); + auto response = read( + [&] + { return http_.get("/tick-size?token_id=" + percent_encode_query_value(token_id)); }); if (!response.ok()) return std::nullopt; @@ -118,7 +120,9 @@ namespace polymarket MetadataFetchCompletion completion(metadata_cache_mutex_, metadata_cache_cv_, metadata_cache_in_flight_, in_flight_key); - auto response = http_.get("/neg-risk?token_id=" + percent_encode_query_value(token_id)); + auto response = read( + [&] + { return http_.get("/neg-risk?token_id=" + percent_encode_query_value(token_id)); }); if (!response.ok()) return std::nullopt; @@ -194,7 +198,7 @@ namespace polymarket path += "&fidelity=" + percent_encode_query_value(fidelity); } - auto response = http_.get(path); + auto response = read([&] { return http_.get(path); }); if (!response.ok()) return result; @@ -242,7 +246,7 @@ namespace polymarket std::optional ClobClient::get_market_live_activity( const std::string &condition_id) { - auto response = http_.get("/markets/live-activity/" + condition_id); + auto response = read([&] { return http_.get("/markets/live-activity/" + condition_id); }); if (!response.ok()) return std::nullopt; diff --git a/src/clob_positions.cpp b/src/clob_positions.cpp index dba7f0d..1183dd1 100644 --- a/src/clob_positions.cpp +++ b/src/clob_positions.cpp @@ -1,4 +1,5 @@ #include "polymarket/clob_client.hpp" +#include "clob_client_internal.hpp" #include "clob_positions_internal.hpp" namespace polymarket @@ -21,16 +22,15 @@ namespace polymarket { return detail::fetch_position_pages( position_address(*this, user_address), detail::PositionFilter::all, - [this](const std::string &path) { return data_http_.get(path); }); + [this](const std::string &path) { return read([&] { return data_http_.get(path); }); }); } std::vector ClobClient::get_redeemable_positions( const std::string &user_address) { return detail::fetch_position_pages( - position_address(*this, user_address), - detail::PositionFilter::redeemable, - [this](const std::string &path) { return data_http_.get(path); }); + position_address(*this, user_address), detail::PositionFilter::redeemable, + [this](const std::string &path) { return read([&] { return data_http_.get(path); }); }); } std::vector ClobClient::get_mergeable_positions( @@ -38,6 +38,6 @@ namespace polymarket { return detail::fetch_position_pages( position_address(*this, user_address), detail::PositionFilter::mergeable, - [this](const std::string &path) { return data_http_.get(path); }); + [this](const std::string &path) { return read([&] { return data_http_.get(path); }); }); } } diff --git a/src/clob_rate_limit.cpp b/src/clob_rate_limit.cpp index 353c552..50e147d 100644 --- a/src/clob_rate_limit.cpp +++ b/src/clob_rate_limit.cpp @@ -13,6 +13,19 @@ namespace polymarket rate_limit_listener_ = std::move(shared); } + void ClobClient::set_rate_limit_retry(std::optional policy) + { + if (policy) detail::validate_rate_limit_retry(*policy); + std::lock_guard lock(rate_limit_mutex_); + rate_limit_retry_ = policy; + } + + std::optional ClobClient::rate_limit_retry() const + { + std::lock_guard lock(rate_limit_mutex_); + return rate_limit_retry_; + } + void ClobClient::notify_rate_limit(const HttpResponse &response, RateLimitUpdate::Bucket bucket) const { diff --git a/tests/test_clob_rate_limit.cpp b/tests/test_clob_rate_limit.cpp index 01c6c19..534b116 100644 --- a/tests/test_clob_rate_limit.cpp +++ b/tests/test_clob_rate_limit.cpp @@ -95,6 +95,116 @@ namespace clob_test "a throwing listener must not change the request result") && check(calls == 1, "an empty listener must stop notifications"); } + + bool test_public_reads_retry_after_429() + { + LocalServer server; + ClobClient client(server.url(), 137); + const std::map retry_now = {{"Retry-After", "0"}}; + + server.enqueue(R"({"error":"slow down"})", 429, retry_now); + server.enqueue(R"({"mid":"0.5"})"); + const auto midpoint = client.get_midpoint("token-1"); + server.enqueue(R"({"error":"slow down"})", 429, retry_now); + server.enqueue(R"({"error":"slow down"})", 429, retry_now); + server.enqueue(R"([])"); + const auto books = client.get_order_books({"token-1"}); + const auto requests = server.requests(); + + return check(midpoint && requests.size() == 5, "GET reads must retry a 429") && + check(requests[0].target == requests[1].target && requests[2].method == "POST" && + requests[2].target == "/books" && requests[4].target == "/books", + "batch POST reads must retry a 429 up to the default two times") && + check(books.empty(), "the retried batch read must return the final response"); + } + + // Retry-After: 1 moves the retry into a new second, so a regenerated L2 + // header carries a new timestamp. + bool test_authenticated_reads_resign_each_attempt() + { + LocalServer server; + auto client = authenticated_client(server.url()); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "1"}}); + server.enqueue(R"({"apiKeys":["test-key"]})"); + const auto keys = client.get_api_keys(); + const auto requests = server.requests(); + + return check(keys == std::vector{"test-key"} && requests.size() == 2, + "authenticated reads must retry a 429") && + check(requests[0].headers.at("poly_timestamp") != + requests[1].headers.at("poly_timestamp"), + "each attempt must carry a fresh L2 timestamp") && + check(requests[1].headers.at("poly_signature") == + expected_signature(requests[1], "/auth/api-keys"), + "the retried request must carry a valid signature"); + } + + bool test_data_api_reads_retry_after_429() + { + LocalServer server; + auto environment = Environment::production(); + environment.clob_url = server.url(); + environment.data_url = server.url(); + ClobClient client(environment); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + server.enqueue(R"([])"); + const auto positions = client.get_positions("0x1111111111111111111111111111111111111111"); + const auto requests = server.requests(); + + return check(positions.empty() && requests.size() == 2 && + requests[1].target.rfind("/positions?", 0) == 0, + "Data API reads must retry a 429"); + } + + bool test_retry_policy_limits() + { + LocalServer server; + ClobClient client(server.url(), 137); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "10"}}); + const bool long_wait_failed = !client.get_midpoint("token-1"); + const auto after_long_wait = server.requests().size(); + + client.set_rate_limit_retry(std::nullopt); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + const bool disabled_failed = !client.get_midpoint("token-1"); + const auto after_disabled = server.requests().size(); + + client.set_rate_limit_retry(RateLimitRetry{1, std::chrono::milliseconds(5000)}); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + const bool exhausted_failed = !client.get_midpoint("token-1"); + const auto after_exhausted = server.requests().size(); + + bool rejected_invalid = false; + try + { + client.set_rate_limit_retry(RateLimitRetry{-1, std::chrono::milliseconds(0)}); + } + catch (const std::invalid_argument &) + { + rejected_invalid = true; + } + + return check(long_wait_failed && after_long_wait == 1, + "a Retry-After above max_delay must fail without retrying") && + check(disabled_failed && after_disabled == 2, "a disabled policy must not retry") && + check(exhausted_failed && after_exhausted == 4, + "retries must stop at the policy count") && + check(rejected_invalid, "an invalid policy must be rejected"); + } + + bool test_writes_are_not_retried() + { + LocalServer server; + auto client = authenticated_client(server.url()); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + const bool cancelled = client.cancel_all(); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "0"}}); + const bool deleted = client.delete_api_key(); + + return check(!cancelled && !deleted && server.requests().size() == 2, + "cancellations and other writes must not be retried"); + } } // namespace clob_test int main() @@ -103,7 +213,10 @@ int main() polymarket::http_global_init(); const bool ok = test_listener_receives_order_and_cancel_buckets() && test_rate_limited_order_is_not_retried() && - test_throwing_listener_and_removal(); + test_throwing_listener_and_removal() && test_public_reads_retry_after_429() && + test_authenticated_reads_resign_each_attempt() && + test_data_api_reads_retry_after_429() && test_retry_policy_limits() && + test_writes_are_not_retried(); polymarket::http_global_cleanup(); return ok ? 0 : 1; } From 9812265e83e8ec6b3e1a7a75ec3db2a09f9bb354 Mon Sep 17 00:00:00 2001 From: yluoc Date: Wed, 7 Oct 2026 11:47:19 -0400 Subject: [PATCH 4/5] feat(position): retry rate-limited Gamma and relayer reads --- cmake/PolymarketTests.cmake | 5 ++ include/polymarket/position_client.hpp | 3 + include/polymarket/types.hpp | 3 + src/market_fetcher.cpp | 20 +++-- src/position_client.cpp | 4 +- src/position_client_impl.hpp | 1 + src/position_senders.cpp | 7 +- src/safe_relayer.cpp | 29 ++++++- src/safe_relayer.hpp | 5 ++ tests/test_clob_rate_limit.cpp | 6 +- tests/test_position_rate_limit.cpp | 102 +++++++++++++++++++++++++ 11 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 tests/test_position_rate_limit.cpp diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index 3337772..5bf8cdd 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -167,6 +167,11 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_clob_rate_limit PRIVATE polymarket::client) add_test(NAME test_clob_rate_limit COMMAND test_clob_rate_limit) + add_executable(test_position_rate_limit tests/test_position_rate_limit.cpp) + target_include_directories(test_position_rate_limit PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(test_position_rate_limit PRIVATE polymarket::client) + add_test(NAME test_position_rate_limit COMMAND test_position_rate_limit) + add_executable(test_websocket_resilience tests/test_websocket_resilience.cpp) target_include_directories(test_websocket_resilience PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(test_websocket_resilience PRIVATE polymarket::client) diff --git a/include/polymarket/position_client.hpp b/include/polymarket/position_client.hpp index e7f9f59..1930fae 100644 --- a/include/polymarket/position_client.hpp +++ b/include/polymarket/position_client.hpp @@ -3,6 +3,7 @@ #include "polymarket/json_rpc_client.hpp" #include "polymarket/order_signer.hpp" #include "polymarket/polymarket_contracts.hpp" +#include "polymarket/rate_limit.hpp" #include "polymarket/trading_approvals.hpp" #include #include @@ -170,6 +171,8 @@ namespace polymarket std::string relayer_api_key_address; // defaults to the signer address long relayer_retry_delay_ms = 2000; // between retried submits int relayer_max_submit_retries = 10; + // Retries for rate-limited Gamma and relayer reads; nullopt disables. + std::optional rate_limit_retry = RateLimitRetry{}; // RPC, Gamma, relayer and contracts from `environment`, which is // validated first. The caller still sets private_key and the wallet. diff --git a/include/polymarket/types.hpp b/include/polymarket/types.hpp index 50fa07d..36852a9 100644 --- a/include/polymarket/types.hpp +++ b/include/polymarket/types.hpp @@ -1,5 +1,6 @@ #pragma once +#include "polymarket/rate_limit.hpp" #include #include #include @@ -274,6 +275,8 @@ namespace polymarket uint64_t max_book_age_ms = 2000; int http_timeout_ms = 5000; int max_markets = 50; + // MarketFetcher retries rate-limited reads with this policy; nullopt disables. + std::optional rate_limit_retry = RateLimitRetry{}; // Crypto tickers for 15m/4h/1h markets std::vector crypto_tickers = { diff --git a/src/market_fetcher.cpp b/src/market_fetcher.cpp index bb5b432..3ed8924 100644 --- a/src/market_fetcher.cpp +++ b/src/market_fetcher.cpp @@ -1,4 +1,5 @@ #include "polymarket/market_fetcher.hpp" +#include "rate_limit_internal.hpp" #include "query_encoding.hpp" #include "rest_orderbook_parsing.hpp" #include @@ -14,6 +15,7 @@ namespace polymarket { http_.set_base_url(config_.clob_rest_url); http_.set_timeout_ms(config_.http_timeout_ms); + if (config_.rate_limit_retry) detail::validate_rate_limit_retry(*config_.rate_limit_retry); } std::vector MarketFetcher::fetch_all_markets(int max_markets) @@ -44,7 +46,8 @@ namespace polymarket ? endpoint : endpoint + "?next_cursor=" + detail::percent_encode_query_value(next_cursor); - const auto response = http_.get(path); + const auto response = detail::retry_rate_limited(config_.rate_limit_retry, + [&] { return http_.get(path); }); if (!response.ok()) { std::cerr << "Failed to fetch markets: " << response.status_code @@ -106,7 +109,8 @@ namespace polymarket const std::string &condition_id) { if (condition_id.empty()) return std::nullopt; - const auto response = http_.get("/markets/" + condition_id); + const auto response = detail::retry_rate_limited( + config_.rate_limit_retry, [&] { return http_.get("/markets/" + condition_id); }); if (!response.ok()) { return std::nullopt; @@ -127,8 +131,12 @@ namespace polymarket std::optional MarketFetcher::fetch_orderbook(const std::string &token_id) { - const auto response = http_.get( - "/book?token_id=" + detail::percent_encode_query_value(token_id)); + const auto response = detail::retry_rate_limited( + config_.rate_limit_retry, + [&] + { + return http_.get("/book?token_id=" + detail::percent_encode_query_value(token_id)); + }); return response.ok() ? parse_orderbook_response(response.body, token_id) : std::nullopt; @@ -136,7 +144,9 @@ namespace polymarket bool MarketFetcher::refresh_market_metadata(MarketState &market) { - const auto response = http_.get("/clob-markets/" + market.condition_id); + const auto response = detail::retry_rate_limited( + config_.rate_limit_retry, + [&] { return http_.get("/clob-markets/" + market.condition_id); }); if (!response.ok()) { return false; diff --git a/src/position_client.cpp b/src/position_client.cpp index 1ee1546..867b110 100644 --- a/src/position_client.cpp +++ b/src/position_client.cpp @@ -1,4 +1,5 @@ #include "polymarket/position_client.hpp" +#include "rate_limit_internal.hpp" #include "position_calls.hpp" #include "position_client_impl.hpp" #include @@ -26,7 +27,8 @@ namespace polymarket std::string path = "/markets?condition_ids=" + normalized; if (closed_only) path += "&closed=true"; - const auto response = impl_->gamma.get(path); + const auto response = detail::retry_rate_limited(impl_->rate_limit_retry, + [&] { return impl_->gamma.get(path); }); if (!response.ok()) throw std::runtime_error("Gamma market lookup failed for condition " + normalized + ": status " + std::to_string(response.status_code) + " " + response.error); diff --git a/src/position_client_impl.hpp b/src/position_client_impl.hpp index 928b01e..370254f 100644 --- a/src/position_client_impl.hpp +++ b/src/position_client_impl.hpp @@ -23,6 +23,7 @@ namespace polymarket std::shared_ptr rpc; std::shared_ptr relayer; // Safe only HttpClient gamma; + std::optional rate_limit_retry; std::chrono::milliseconds relayer_retry_delay; int relayer_max_submit_retries; std::mutex send_mutex; // one nonce sequence at a time diff --git a/src/position_senders.cpp b/src/position_senders.cpp index 7d71e9c..2e1134b 100644 --- a/src/position_senders.cpp +++ b/src/position_senders.cpp @@ -1,4 +1,5 @@ #include "polymarket/evm_transaction.hpp" +#include "rate_limit_internal.hpp" #include "evm_uint.hpp" #include "polymarket/evm_utils.hpp" #include "position_client_impl.hpp" @@ -28,6 +29,8 @@ namespace polymarket relayer_retry_delay(config.relayer_retry_delay_ms), relayer_max_submit_retries(config.relayer_max_submit_retries) { + if (config.rate_limit_retry) detail::validate_rate_limit_retry(*config.rate_limit_retry); + rate_limit_retry = config.rate_limit_retry; rpc->set_timeout_ms(config.rpc_timeout_ms); gamma.set_base_url(config.gamma_api_url); @@ -42,7 +45,9 @@ namespace polymarket throw std::invalid_argument("POLY_GNOSIS_SAFE needs relayer_api_key"); relayer = std::make_shared( config.relayer_url, config.relayer_api_key, - config.relayer_api_key_address.empty() ? signer_address : config.relayer_api_key_address); + config.relayer_api_key_address.empty() ? signer_address + : config.relayer_api_key_address, + rate_limit_retry); } else { diff --git a/src/safe_relayer.cpp b/src/safe_relayer.cpp index c186941..9bb58d8 100644 --- a/src/safe_relayer.cpp +++ b/src/safe_relayer.cpp @@ -1,4 +1,5 @@ #include "safe_relayer.hpp" +#include "rate_limit_internal.hpp" #include "polymarket/evm_abi.hpp" #include "evm_uint.hpp" #include "polymarket/evm_utils.hpp" @@ -209,8 +210,18 @@ namespace polymarket::detail return std::regex_search(body, match, nonce_mismatch) && decimal_less(match[1].str(), match[2].str()); } - RelayerClient::RelayerClient(const std::string &base_url, std::string api_key, std::string api_key_address) - : headers_{{"RELAYER_API_KEY", std::move(api_key)}, {"RELAYER_API_KEY_ADDRESS", std::move(api_key_address)}} + RelayerClient::RelayerClient(const std::string &base_url, std::string api_key, + std::string api_key_address) + : RelayerClient(base_url, std::move(api_key), std::move(api_key_address), RateLimitRetry{}) + { + } + + RelayerClient::RelayerClient(const std::string &base_url, std::string api_key, + std::string api_key_address, + std::optional read_retry) + : headers_{{"RELAYER_API_KEY", std::move(api_key)}, + {"RELAYER_API_KEY_ADDRESS", std::move(api_key_address)}}, + read_retry_(read_retry) { http_.set_base_url(base_url); } @@ -236,7 +247,15 @@ namespace polymarket::detail std::string RelayerClient::safe_nonce(const std::string &signer_address) { const std::string endpoint = "/v1/account/transactions/params"; - const auto body = parse(http_.get(endpoint + "?address=" + signer_address + "&type=SAFE", headers_), endpoint); + const auto body = + parse(retry_rate_limited(read_retry_, + [&] + { + return http_.get(endpoint + "?address=" + signer_address + + "&type=SAFE", + headers_); + }), + endpoint); const auto nonce = string_field(body, "nonce", endpoint, true); if (!is_digits(nonce)) throw std::runtime_error("invalid relayer response from " + endpoint + ": nonce is not numeric"); @@ -259,7 +278,9 @@ namespace polymarket::detail RelayerTransaction RelayerClient::get_transaction(const std::string &transaction_id) { const std::string endpoint = "/v1/account/transactions/" + transaction_id; - const auto body = parse(http_.get(endpoint, headers_), endpoint); + const auto body = + parse(retry_rate_limited(read_retry_, [&] { return http_.get(endpoint, headers_); }), + endpoint); if (!body.contains("transaction_hash")) throw std::runtime_error("invalid relayer response from " + endpoint + ": missing transaction_hash"); RelayerTransaction tx; diff --git a/src/safe_relayer.hpp b/src/safe_relayer.hpp index 7cf8deb..baa46bb 100644 --- a/src/safe_relayer.hpp +++ b/src/safe_relayer.hpp @@ -80,6 +80,10 @@ namespace polymarket::detail public: RelayerClient(const std::string &base_url, std::string api_key, std::string api_key_address); + // read_retry applies to the nonce and status reads, never to /submit. + RelayerClient(const std::string &base_url, std::string api_key, std::string api_key_address, + std::optional read_retry); + // Next Safe nonce for the Safe owned by signer_address. std::string safe_nonce(const std::string &signer_address); RelayerTransaction submit(const nlohmann::json &payload); @@ -90,5 +94,6 @@ namespace polymarket::detail HttpClient http_; std::map headers_; + std::optional read_retry_; }; } // namespace polymarket::detail diff --git a/tests/test_clob_rate_limit.cpp b/tests/test_clob_rate_limit.cpp index 534b116..e117718 100644 --- a/tests/test_clob_rate_limit.cpp +++ b/tests/test_clob_rate_limit.cpp @@ -118,13 +118,13 @@ namespace clob_test check(books.empty(), "the retried batch read must return the final response"); } - // Retry-After: 1 moves the retry into a new second, so a regenerated L2 - // header carries a new timestamp. + // L2 timestamps have one-second resolution on the wall clock, so a 2 s + // Retry-After leaves margin for clock adjustment between attempts. bool test_authenticated_reads_resign_each_attempt() { LocalServer server; auto client = authenticated_client(server.url()); - server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "1"}}); + server.enqueue(R"({"error":"slow down"})", 429, {{"Retry-After", "2"}}); server.enqueue(R"({"apiKeys":["test-key"]})"); const auto keys = client.get_api_keys(); const auto requests = server.requests(); diff --git a/tests/test_position_rate_limit.cpp b/tests/test_position_rate_limit.cpp new file mode 100644 index 0000000..7b4d9f3 --- /dev/null +++ b/tests/test_position_rate_limit.cpp @@ -0,0 +1,102 @@ +#include "polymarket/market_fetcher.hpp" +#include "position_test_support.hpp" +#include "safe_relayer.hpp" + +#include + +using namespace position_test; + +namespace +{ + const std::map retry_now = {{"Retry-After", "0"}}; + constexpr const char *slow_down = R"({"error":"slow down"})"; + + PositionClientConfig eoa_config(const std::string &gamma_url) + { + PositionClientConfig config; + config.private_key = kKey; + config.rpc_url = "http://127.0.0.1:1"; + config.gamma_api_url = gamma_url; + return config; + } + + void gamma_lookup_retries() + { + clob_test::LocalServer gamma; + PositionClient client(eoa_config(gamma.url())); + gamma.enqueue(slow_down, 429, retry_now); + gamma.enqueue("[]"); + expect_throws("empty market lookup", + [&] { client.resolve_market(kCondition); }); + check(gamma.requests().size() == 2, "a rate-limited Gamma lookup must be retried"); + + auto disabled_config = eoa_config(gamma.url()); + disabled_config.rate_limit_retry = std::nullopt; + PositionClient disabled(disabled_config); + gamma.enqueue(slow_down, 429, retry_now); + expect_throws("rate-limited lookup", + [&] { disabled.resolve_market(kCondition); }); + check(gamma.requests().size() == 3, "a disabled policy must not retry Gamma"); + + auto invalid = eoa_config(gamma.url()); + invalid.rate_limit_retry = RateLimitRetry{-1, std::chrono::milliseconds(0)}; + expect_throws("invalid PositionClient policy", + [&] { PositionClient bad(invalid); }); + } + + void relayer_reads_retry_but_submit_does_not() + { + clob_test::LocalServer server; + detail::RelayerClient relayer(server.url(), "relayer-key", kWallet); + const nlohmann::json transaction = {{"transaction_id", "tx-1"}, + {"transaction_hash", "0x1"}, + {"state", "STATE_MINED"}, + {"error_msg", ""}}; + + server.enqueue(slow_down, 429, retry_now); + server.enqueue(transaction.dump()); + check(relayer.get_transaction("tx-1").transaction_id == "tx-1", + "status read must succeed after a 429"); + server.enqueue(slow_down, 429, retry_now); + server.enqueue(R"({"nonce":"7"})"); + check(relayer.safe_nonce(kWallet) == "7", "nonce read must succeed after a 429"); + check(server.requests().size() == 4, "relayer reads must retry a 429"); + + server.enqueue(slow_down, 429, retry_now); + expect_throws("rate-limited submit", [&] + { relayer.submit(nlohmann::json::object()); }); + check(server.requests().size() == 5, "relayer submits must not be retried here"); + } + + void market_fetcher_retries() + { + clob_test::LocalServer server; + Config config; + config.clob_rest_url = server.url(); + MarketFetcher fetcher(config); + server.enqueue(slow_down, 429, retry_now); + server.enqueue(R"({"asset_id":"token-1","bids":[],"asks":[]})"); + const auto book = fetcher.fetch_orderbook("token-1"); + check(book && server.requests().size() == 2, "MarketFetcher reads must retry a 429"); + + config.rate_limit_retry = std::nullopt; + MarketFetcher disabled(config); + server.enqueue(slow_down, 429, retry_now); + check(!disabled.fetch_orderbook("token-1") && server.requests().size() == 3, + "a disabled MarketFetcher policy must not retry"); + + config.rate_limit_retry = RateLimitRetry{1, std::chrono::milliseconds(-1)}; + expect_throws("invalid MarketFetcher policy", + [&] { MarketFetcher bad(config); }); + } +} // namespace + +int main() +{ + http_global_init(); + gamma_lookup_retries(); + relayer_reads_retry_but_submit_does_not(); + market_fetcher_retries(); + http_global_cleanup(); + return check_support::finish("test_position_rate_limit"); +} From cd32654ad8bb821909849cb5b627d7545fbe2542 Mon Sep 17 00:00:00 2001 From: yluoc Date: Wed, 7 Oct 2026 11:48:55 -0400 Subject: [PATCH 5/5] docs: describe rate-limit retries and updates --- README.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0bfa4d3..21cd191 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,29 @@ if (!result) { ``` `SdkError` includes the endpoint, HTTP status, response excerpt, retryability, -and the request ID when the server returns one. +and the request ID when the server returns one. Rejections also carry +`retry_after_seconds` and the `Poly-RateLimit-*` state when the server sends them. + +### Rate limits + +Reads that get HTTP 429 are retried up to twice, each after exactly the +server's `Retry-After` delay (1 s when absent). A requested delay above 5 s +returns the error instead. Orders, cancellations, and other writes are never +retried; check `retry_after_seconds` on their error and decide yourself. + +```cpp +client.set_rate_limit_retry(polymarket::RateLimitRetry{1, std::chrono::seconds(2)}); +client.set_rate_limit_retry(std::nullopt); // fail on the first 429 + +client.set_rate_limit_listener([](const polymarket::RateLimitUpdate &update) { + if (update.warning) std::cerr << "order rate would be rejected under enforcement\n"; +}); +``` + +The listener receives the `Poly-RateLimit-*` headers from order and cancel +responses. `PositionClientConfig::rate_limit_retry` and `Config::rate_limit_retry` +set the same policy for `PositionClient` and `MarketFetcher`. Unlike the official +SDKs, CLOB reads are retried too; disable it for latency-critical paths. ### On-chain positions