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
+37
View File
@@ -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();