fix(market): back off failed market fetches instead of retrying every frame
The Market tab calls the exchange-list, price-history and candle fetches every frame, so a failed fetch (offline, rate-limited, a venue's API down) was retried on the very next frame, ~20 requests a second. - util::FetchBackoff: 30 s doubling to a 10 min cap, +/-20% jitter. The exchange list, the price history (a partial result counts as a failure) and the per-pair candles each back off on failure. - Candle backoff is per pair. Switching to another pair still loads at once, a failed refresh keeps the on-screen pair's last candles, and switching back to a pair that is backing off shows the aggregate chart rather than the previous pair's candles. - The Market tab's refresh button retries at once (at most every 5 s). A price fetch (60 s timer, no backoff) that succeeds after failing means the network is back, so it lifts all three backoffs. - A partial price history no longer marks the chart loaded: with no fetch timestamp, the 30-minute reload gate measured system uptime and blocked retries for the first 30 minutes after boot. - The exchange list goes through httpGetString (TLS verified, size capped) with short timeouts (5 s connect, 12 s total, as it runs on the shared RPC worker) instead of its own curl code. A failed fetch is retried; it used to be tried only once per session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_; // "<identifier>:<BASE>/<QUOTE>" of the loaded series ("" = none)
|
||||
|
||||
+79
-40
@@ -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<data::ExchangeInfo> 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());
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <random>
|
||||
|
||||
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<double>(kBaseSeconds) * static_cast<double>(1 << doublings),
|
||||
static_cast<double>(kCapSeconds))
|
||||
* (1.0 + 0.2 * std::clamp(jitter, -1.0, 1.0));
|
||||
return std::chrono::duration_cast<Clock::duration>(std::chrono::duration<double>(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<double>(-1.0, 1.0)(rng);
|
||||
}
|
||||
|
||||
private:
|
||||
int failures_ = 0;
|
||||
Clock::time_point next_{};
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -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<curl_off_t>(kMaxMetadataBytes)); // reject oversized metadata
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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<Clock::duration>(seconds(36)));
|
||||
EXPECT_TRUE(FetchBackoff::delayFor(1, -1.0) == std::chrono::duration_cast<Clock::duration>(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();
|
||||
|
||||
Reference in New Issue
Block a user