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
+65
View File
@@ -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
+4 -3
View File
@@ -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);
+5 -3
View File
@@ -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)`