diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e80536..047c87d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -697,6 +697,7 @@ set(APP_HEADERS src/data/exchange_info.h src/util/logger.h src/util/async_task_manager.h + src/util/fetch_backoff.h src/util/amount_format.h src/util/base64.h src/util/single_instance.h diff --git a/src/app.h b/src/app.h index 73d548c..1c9af2e 100644 --- a/src/app.h +++ b/src/app.h @@ -28,6 +28,7 @@ #include "services/wallet_security_controller.h" #include "services/wallet_security_workflow.h" #include "util/async_task_manager.h" +#include "util/fetch_backoff.h" #include "util/pool_stats_service.h" #include "wallet/wallet_capabilities.h" #include "chat/chat_service.h" @@ -441,6 +442,9 @@ public: void refreshMiningInfo(); void refreshPeerInfo(); void refreshMarketData(); + // The Market tab's refresh button: lift any failure backoff (at most once per 5 s, against click + // spam), then refreshMarketData(). + void retryMarketDataNow(); // Fetch the live exchange/pair list from CoinGecko once per session (venues are // near-static); populates state.market.exchanges. Safe to call every frame. void refreshExchanges(); @@ -984,8 +988,16 @@ private: long long last_balance_eval_ms_ = 0; // steady-clock ms of the last refresh kick bool balance_refresh_pending_ = false; // UI asked for an immediate refresh bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied - bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch + bool exchanges_fetch_started_ = false; // CoinGecko tickers fetch in flight or done (once per session) bool chart_fetch_in_flight_ = false; // a market_chart history fetch is on the worker + // Failed market fetches back off instead of retrying: the Market tab calls these every frame, so a + // failure (offline, rate-limited) used to retry on the very next frame, ~20 requests a second. + util::FetchBackoff exchanges_backoff_; + util::FetchBackoff market_chart_backoff_; + util::FetchBackoff exchange_chart_backoff_; // for exchange_chart_fail_key_ only: other pairs still load + std::string exchange_chart_fail_key_; + std::chrono::steady_clock::time_point market_manual_retry_at_{}; + bool price_fetch_failed_ = false; // last price fetch failed (connectivity probe for the backoffs) // Per-exchange candle chart (refreshExchangeChart): which pair the loaded series is for, an // in-flight guard, and a slow refresh timer (candles move slowly, like the aggregate chart). std::string exchange_chart_key_; // ":/" of the loaded series ("" = none) diff --git a/src/app_network.cpp b/src/app_network.cpp index 45f1ef4..2820483 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2453,8 +2453,18 @@ void App::refreshPrice() return [this, result = std::move(result)]() mutable { if (result.price) { NetworkRefreshService::applyPriceRefreshResult(state_, *result.price, std::chrono::steady_clock::now()); + if (price_fetch_failed_) { + // The price (60 s timer, no backoff) works again after failing: the network is back, so + // retry the backed-off market fetches now rather than after up to ~12 min. Failure + // counts stay, so an endpoint that keeps failing on its own still waits. + price_fetch_failed_ = false; + exchanges_backoff_.forceDue(); + market_chart_backoff_.forceDue(); + exchange_chart_backoff_.forceDue(); + } } else { NetworkRefreshService::applyPriceRefreshFailure(state_, result.errorMessage); + price_fetch_failed_ = true; } }; }, 0); @@ -2470,6 +2480,18 @@ void App::refreshMarketData() refreshExchangeChart(); } +void App::retryMarketDataNow() +{ + const auto now = std::chrono::steady_clock::now(); + if (now >= market_manual_retry_at_) { // at most every 5 s, so click spam can't hammer the APIs + market_manual_retry_at_ = now + std::chrono::seconds(5); + exchanges_backoff_.forceDue(); + market_chart_backoff_.forceDue(); + exchange_chart_backoff_.forceDue(); + } + refreshMarketData(); +} + void App::refreshExchangeChart() { if (!settings_ || !settings_->getFetchPrices() || !worker_) return; @@ -2516,8 +2538,13 @@ void App::refreshExchangeChart() exchange_chart_last_fetch_ = it->second.fetchedAt; return; } - // Switching pairs: fall back to the aggregate until the new venue's candles arrive. + // Switching pairs: fall back to the aggregate until the new venue's candles arrive. Before the + // backoff check, so switching back to a pair that is backing off doesn't keep showing the previous + // pair's candles. if (key != exchange_chart_key_) state_.market.exchange_chart_active = false; + // This pair's last fetch failed: wait out the backoff (switching to another pair still loads at once). + if (key == exchange_chart_fail_key_ && !exchange_chart_backoff_.due(std::chrono::steady_clock::now())) + return; exchange_chart_fetch_in_flight_ = true; const std::string identifier = pair->identifier, base = pair->base, quote = pair->quote; @@ -2553,10 +2580,23 @@ void App::refreshExchangeChart() exchange_chart_last_fetch_ = e.fetchedAt; if (exchange_chart_cache_.size() >= 16) exchange_chart_cache_.clear(); // bound it (many venues) exchange_chart_cache_[key] = std::move(e); + if (key == exchange_chart_fail_key_) { + exchange_chart_fail_key_.clear(); + exchange_chart_backoff_.succeed(); + } } else { - // Both ranges empty -> the venue's API failed/changed; keep the aggregate. - state_.market.exchange_chart_active = false; - exchange_chart_key_.clear(); + // Both ranges empty: the venue's API failed or changed. Back off for this pair (another + // pair still loads at once). A refresh of the pair on screen keeps its last candles; a + // first load falls back to the aggregate chart. + if (exchange_chart_fail_key_ != key) { + exchange_chart_fail_key_ = key; + exchange_chart_backoff_.succeed(); // a fresh failure count for this pair + } + exchange_chart_backoff_.fail(std::chrono::steady_clock::now(), util::FetchBackoff::randomJitter()); + if (exchange_chart_key_ != key) { + state_.market.exchange_chart_active = false; + exchange_chart_key_.clear(); + } } }; }); @@ -2567,10 +2607,13 @@ void App::refreshMarketChart() if (!settings_ || !settings_->getFetchPrices()) return; if (!worker_) return; if (chart_fetch_in_flight_) return; + const auto now = std::chrono::steady_clock::now(); // Historical data moves slowly — refresh at most every 30 minutes after the first fetch. - if (state_.market.chart_loaded && - std::chrono::steady_clock::now() - state_.market.chart_last_fetch_time < std::chrono::minutes(30)) + if (state_.market.chart_loaded && now - state_.market.chart_last_fetch_time < std::chrono::minutes(30)) return; + // The last attempt failed (or got only one range): wait out the backoff instead of retrying on the + // next frame, as this is called every frame the Market tab is open. + if (!market_chart_backoff_.due(now)) return; chart_fetch_in_flight_ = true; worker_->post([this]() -> rpc::RPCWorker::MainCb { @@ -2589,14 +2632,21 @@ void App::refreshMarketChart() return [this, intraday = std::move(intraday), daily = std::move(daily)]() mutable { chart_fetch_in_flight_ = false; - const bool any = !intraday.empty() || !daily.empty(); - if (!intraday.empty()) state_.market.price_chart_intraday = std::move(intraday); - if (!daily.empty()) state_.market.price_chart_daily = std::move(daily); - if (any) { + const bool gotIntraday = !intraday.empty(); + const bool gotDaily = !daily.empty(); + if (gotIntraday) state_.market.price_chart_intraday = std::move(intraday); + if (gotDaily) state_.market.price_chart_daily = std::move(daily); + if (gotIntraday && gotDaily) { + // chart_loaded only with the timestamp: it arms the 30-minute gate above, which a partial + // result must not (with no timestamp it would measure system uptime instead). state_.market.chart_loaded = true; state_.market.chart_last_fetch_time = std::chrono::steady_clock::now(); + market_chart_backoff_.succeed(); + } else { + // Total or partial failure: show what arrived, and retry (both ranges) after the backoff + // rather than on the next frame or, for a partial result, only in 30 minutes. + market_chart_backoff_.fail(std::chrono::steady_clock::now(), util::FetchBackoff::randomJitter()); } - // On total failure, leave chart_loaded=false so the next tick retries. }; }); } @@ -2605,42 +2655,31 @@ void App::refreshExchanges() { if (!settings_ || !settings_->getFetchPrices()) return; if (!worker_) return; - if (exchanges_fetch_started_) return; // once per session (venues are near-static) + if (exchanges_fetch_started_) return; // in flight, or loaded (once per session: venues are near-static) + // The last attempt failed: wait out the backoff. This is called every frame the Market tab is open, + // and a failure used to retry on the very next frame. + if (!exchanges_backoff_.due(std::chrono::steady_clock::now())) return; exchanges_fetch_started_ = true; worker_->post([this]() -> rpc::RPCWorker::MainCb { + // httpGetString verifies TLS, caps the response size and returns "" on any failure. Short + // timeouts: this runs on the shared RPC worker. + const std::string body = util::httpGetString( + "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers", "[tickers]", + /*connectTimeoutSec=*/5, /*timeoutSec=*/12); std::vector exchanges; - try { - CURL* curl = curl_easy_init(); - if (curl) { - std::string body; - const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers"; - auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t { - size_t total = size * nmemb; - userp->append((char*)contents, total); - return total; - }; - curl_easy_setopt(curl, CURLOPT_URL, url); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L); - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); - curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); - curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0"); - CURLcode res = curl_easy_perform(curl); - long http_code = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); - curl_easy_cleanup(curl); - if (res == CURLE_OK && http_code >= 200 && http_code < 300) - exchanges = data::parseCoinGeckoTickers(body); - } - } catch (...) {} + if (!body.empty()) { + try { exchanges = data::parseCoinGeckoTickers(body); } catch (...) {} + } return [this, exchanges = std::move(exchanges)]() mutable { - if (!exchanges.empty()) + if (!exchanges.empty()) { state_.market.exchanges = std::move(exchanges); - else - exchanges_fetch_started_ = false; // allow a retry if it failed/empty + exchanges_backoff_.succeed(); + } else { + exchanges_fetch_started_ = false; // retry once the backoff is over + exchanges_backoff_.fail(std::chrono::steady_clock::now(), util::FetchBackoff::randomJitter()); + } }; }); } diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index c82660f..08457ec 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -1681,7 +1681,7 @@ static void mktDrawPriceChart(const MktCtx& cx) refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH); ImGui::SetCursorScreenPos(rbMin); if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(pillH, pillH))) { - app->refreshMarketData(); + app->retryMarketDataNow(); // also lifts a failure backoff, so a manual retry runs now } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price")); diff --git a/src/util/fetch_backoff.h b/src/util/fetch_backoff.h new file mode 100644 index 0000000..26e0f27 --- /dev/null +++ b/src/util/fetch_backoff.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include + +namespace dragonx { +namespace util { + +/** + * Exponential backoff for a fetch that can keep failing (offline, rate-limited, an API change). + * + * After the n-th consecutive failure the next attempt waits 30 s * 2^(n-1), capped at 10 min, scaled + * by up to +/-20% jitter so many wallets don't all retry in step when an outage ends. Not thread-safe: + * keep it on the main thread (check due() before posting, record the outcome in the result callback). + */ +class FetchBackoff { +public: + using Clock = std::chrono::steady_clock; + + static constexpr int kBaseSeconds = 30; + static constexpr int kCapSeconds = 600; + + /// True when a fetch may run: no failure yet, or the wait after the last one is over. + bool due(Clock::time_point now) const { return failures_ == 0 || now >= next_; } + + int failures() const { return failures_; } + + void succeed() { failures_ = 0; next_ = {}; } + + /// Record a failure; `jitter` in [-1, 1] scales the wait by up to +/-20%. + void fail(Clock::time_point now, double jitter = 0.0) + { + failures_ = std::min(failures_ + 1, 30); + next_ = now + delayFor(failures_, jitter); + } + + /// Allow the next attempt right away (a manual retry). The failure count stays, so another + /// failure waits longer. + void forceDue() { next_ = {}; } + + static Clock::duration delayFor(int failures, double jitter) + { + if (failures <= 0) return Clock::duration::zero(); + const int doublings = std::min(failures - 1, 10); + const double seconds = std::min(static_cast(kBaseSeconds) * static_cast(1 << doublings), + static_cast(kCapSeconds)) + * (1.0 + 0.2 * std::clamp(jitter, -1.0, 1.0)); + return std::chrono::duration_cast(std::chrono::duration(seconds)); + } + + /// A jitter value in [-1, 1] for fail(). + static double randomJitter() + { + static std::minstd_rand rng{std::random_device{}()}; + return std::uniform_real_distribution(-1.0, 1.0)(rng); + } + +private: + int failures_ = 0; + Clock::time_point next_{}; +}; + +} // namespace util +} // namespace dragonx diff --git a/src/util/http_download.cpp b/src/util/http_download.cpp index 1f3af2a..58164ec 100644 --- a/src/util/http_download.cpp +++ b/src/util/http_download.cpp @@ -49,7 +49,8 @@ int xferCb(void* clientp, curl_off_t dltotal, curl_off_t dlnow, curl_off_t, curl } // namespace -std::string httpGetString(const std::string& url, const char* logTag) +std::string httpGetString(const std::string& url, const char* logTag, + long connectTimeoutSec, long timeoutSec) { CURL* curl = curl_easy_init(); if (!curl) return {}; @@ -59,8 +60,8 @@ std::string httpGetString(const std::string& url, const char* logTag) curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "ObsidianDragon/1.0"); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); - curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, timeoutSec); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, connectTimeoutSec); curl_easy_setopt(curl, CURLOPT_MAXFILESIZE_LARGE, static_cast(kMaxMetadataBytes)); // reject oversized metadata curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); diff --git a/src/util/http_download.h b/src/util/http_download.h index 3ec9a8e..d6dcdba 100644 --- a/src/util/http_download.h +++ b/src/util/http_download.h @@ -18,9 +18,11 @@ namespace dragonx { namespace util { // GET `url` over TLS (peer + host verified) into a string. Follows redirects; 30s transfer / -// 15s connect timeout; browser-agnostic User-Agent. Returns "" on ANY failure (curl error or -// non-2xx status). `logTag` (e.g. "[xmrig-updater]") prefixes the debug log on failure. -std::string httpGetString(const std::string& url, const char* logTag); +// 15s connect timeout by default; browser-agnostic User-Agent; responses over 16 MiB are refused. +// Returns "" on ANY failure (curl error or non-2xx status). `logTag` (e.g. "[xmrig-updater]") +// prefixes the debug log on failure. +std::string httpGetString(const std::string& url, const char* logTag, + long connectTimeoutSec = 15, long timeoutSec = 30); // Download `url` over TLS (peer + host verified) to `destPath`, refusing bodies larger than // `maxBytes` (a defense ahead of the checksum/signature check). `onProgress(downloaded, total)` diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index c5fa764..03a720c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15,6 +15,7 @@ #include "daemon/daemon_busy_detect.h" #include "daemon/embedded_daemon.h" #include "util/connect_stall.h" +#include "util/fetch_backoff.h" #include "util/logger.h" #include "data/transaction_history_cache.h" #include "data/address_book.h" @@ -4374,6 +4375,41 @@ void testRpcKindClasses() } } +// Failed market fetches back off exponentially instead of retrying every frame. +void testFetchBackoff() +{ + using dragonx::util::FetchBackoff; + using Clock = FetchBackoff::Clock; + using std::chrono::seconds; + const Clock::time_point t0 = Clock::time_point{} + std::chrono::hours(1); + + FetchBackoff b; + EXPECT_TRUE(b.due(t0)); // nothing failed yet + b.fail(t0); // 1st failure: 30 s + EXPECT_FALSE(b.due(t0 + seconds(29))); + EXPECT_TRUE(b.due(t0 + seconds(30))); + b.fail(t0); // 2nd: 60 s + EXPECT_FALSE(b.due(t0 + seconds(59))); + EXPECT_TRUE(b.due(t0 + seconds(60))); + for (int i = 0; i < 20; ++i) b.fail(t0); // many failures: capped at 10 min + EXPECT_FALSE(b.due(t0 + seconds(599))); + EXPECT_TRUE(b.due(t0 + seconds(600))); + b.forceDue(); // manual retry: allowed now... + EXPECT_TRUE(b.due(t0)); + EXPECT_TRUE(b.failures() > 0); // ...but the next failure still waits long + b.succeed(); + EXPECT_TRUE(b.due(t0)); + EXPECT_EQ(b.failures(), 0); + + // Jitter scales the wait by at most +/-20%. + EXPECT_TRUE(FetchBackoff::delayFor(1, 1.0) == std::chrono::duration_cast(seconds(36))); + EXPECT_TRUE(FetchBackoff::delayFor(1, -1.0) == std::chrono::duration_cast(seconds(24))); + for (int i = 0; i < 50; ++i) { + const double j = FetchBackoff::randomJitter(); + EXPECT_TRUE(j >= -1.0 && j <= 1.0); + } +} + void testConnectRetryBackoff() { using dragonx::rpc::ConnectRetryBackoff; @@ -9465,6 +9501,7 @@ int main() testRpcBusyGate(); testRpcKindClasses(); testConnectRetryBackoff(); + testFetchBackoff(); testNetworkRefreshService(); testNetworkRefreshSnapshotHelpers(); testParseDaemonRescanOutput();