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:
2026-10-03 19:52:10 -05:00
co-authored by Claude Opus 5.5
parent 16e0ee9947
commit 7ceff1eaaa
8 changed files with 205 additions and 48 deletions
+79 -40
View File
@@ -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());
}
};
});
}