From ca1924f3cb82afee7d1234d0ea3c3ab819ad3cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Sun, 20 Sep 2026 21:59:29 +0200 Subject: [PATCH 1/2] [PictureLoader] Seed per-host allowances on demand and skip hosts in 429 backoff The quota reset re-filled every host's remaining allowance to a full MAX_REQUESTS_PER_SEC as soon as the queue had a request for it, so a server that was just rate limited could be hammered again at full speed immediately after (or even during) recovery. Only seed a host's allowance the first time it is dispatched in the current second, seeded from its reduced sustained quota, and skip hosts still inside their 429 backoff window entirely, handing the entry back to its worker so it can wait the backoff out or fall through to another source instead of parking in the queue with no reply pending. Deferrals wait on the host that is actually blocking the request (cached-redirect targets and the reply host of a 429) rather than the current card URL's host. Rebased onto network-requests/request-pacing, which absorbed the earlier pacing and dispatch-guard commits, and reuses its updateTimerState idle-429 recovery plumbing. --- .../card_picture_loader_worker.cpp | 38 +++++++++++++++---- .../card_picture_loader_worker_work.cpp | 19 +++++++--- .../card_picture_loader_worker_work.h | 38 ++++++++++++------- 3 files changed, 68 insertions(+), 27 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 728c5cc6d..d8779863e 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -107,6 +107,14 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Check for cached redirects QUrl cachedRedirect = getCachedRedirect(url); if (!cachedRedirect.isEmpty()) { + // The redirect target is a different host, which may itself be in 429 backoff; hand the + // entry back to its worker so it waits the backoff out instead of dispatching straight + // onto the backed-off host. + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(), + QDateTime::currentDateTime())) { + worker->scheduleDeferredRetry(cachedRedirect.host()); + return nullptr; + } emit imageRequestSucceeded(url); return makeRequest(cachedRedirect, worker); } @@ -143,10 +151,10 @@ void CardPictureLoaderWorker::resetRequestQuota() } } - for (const auto &request : requestLoadQueue) { - const QString host = request.first.host(); - hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); - } + // Forget the per-second allowances; each host's allowance is re-seeded lazily from its + // reduced sustained quota the first time it is dispatched in the new second, so a host that + // enters the queue mid-second no longer falls through to a fresh full quota. + hostQuotaRemaining.clear(); updateTimerState(); } @@ -212,14 +220,28 @@ void CardPictureLoaderWorker::updateTimerState() bool CardPictureLoaderWorker::processSingleRequest() { + QDateTime now = QDateTime::currentDateTime(); for (int i = 0; i < requestLoadQueue.size(); ++i) { const auto &request = requestLoadQueue.at(i); - QString host = request.first.host(); - int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC); + const QString host = request.first.host(); + // Don't dispatch requests to a host that is currently in its 429 backoff; hand the entry + // back to its worker so it can wait the backoff out or fall through to another source, + // instead of leaving it parked in the queue with no reply pending. + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + auto entry = requestLoadQueue.takeAt(i); + entry.second->startNextPicDownload(); + return true; + } + // Seed the allowance now so a host that was rate limited gets its reduced + // allowance instead of a fresh full quota mid-second. + if (!hostQuotaRemaining.contains(host)) { + hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); + } + int allowance = hostQuotaRemaining.value(host); if (allowance > 0) { hostQuotaRemaining.insert(host, allowance - 1); - makeRequest(request.first, request.second); - requestLoadQueue.removeAt(i); + auto entry = requestLoadQueue.takeAt(i); + makeRequest(entry.first, entry.second); return true; } } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp index 66c56337c..b70207ff4 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp @@ -22,6 +22,11 @@ static const QStringList MD5_BLACKLIST = { "fbc7d763c08771c260b39e2115414eeb" // Current card back hash }; +const ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter() +{ + return s_rateLimiter; +} + CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad) : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)), picDownload(SettingsCache::instance().downloads().getPicDownload()) @@ -168,7 +173,7 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host << ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url"; - scheduleDeferredRetry(); + scheduleDeferredRetry(host); } else { qCWarning(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -273,14 +278,16 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) return imgReader.read(); } -void CardPictureLoaderWorkerWork::scheduleDeferredRetry() +void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost) { QDateTime now = QDateTime::currentDateTime(); - // Prefer waiting on the current URL's server so we retry the same source. - QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host(); - QDateTime backoffUntil = s_rateLimiter.deadline(currentHost); - if (!s_rateLimiter.isRateLimited(currentHost, now)) { + // Prefer waiting on the server that is actually blocking the request: callers hand in the + // rate-limited host when it differs from the current URL (e.g. a cached redirect target still + // in backoff), otherwise fall back to the current URL's server so we retry the same source. + QString waitHost = preferredHost.isEmpty() ? QUrl(cardToDownload.getCurrentUrl()).host() : preferredHost; + QDateTime backoffUntil = s_rateLimiter.deadline(waitHost); + if (!s_rateLimiter.isRateLimited(waitHost, now)) { backoffUntil = s_rateLimiter.earliestDeadline(now); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index 1e56a4373..c5aa07d10 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,30 @@ public: CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading + /** @brief Shared per-server 429 backoff state. */ + static const ServerRateLimiter &rateLimiter(); + + /** + * @brief Starts downloading the next URL for this card. + * + * Skips URLs whose server is currently in 429 backoff, either waiting the + * backoff out or falling through to the other configured sources. Also used by + * the dispatch machinery to hand an entry back after it was removed from the + * request queue when its host turned out to be backed off. + */ + void startNextPicDownload(); + + /** + * @brief Schedules a deferred retry after the relevant server backoff expires. + * @param preferredHost The server that is actually blocking the request, or an empty + * string to use the current URL's server + * + * Waits on the blocking server's backoff deadline, otherwise on the earliest active + * backoff. If no servers are in backoff, concludes with failure. Otherwise resets the + * CardPictureToLoad indices and retries after the backoff period. + */ + void scheduleDeferredRetry(const QString &preferredHost = {}); + public slots: /** * @brief Handles a finished network reply for the card image. @@ -55,9 +80,6 @@ private: static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state - /** @brief Starts downloading the next URL for this card. */ - void startNextPicDownload(); - /** @brief Called when all URLs have been exhausted or download failed. */ void picDownloadFailed(); @@ -82,16 +104,6 @@ private: */ void concludeImageLoad(const QImage &image); - /** - * @brief Schedules a deferred retry after the relevant server backoff expires. - * - * Waits on the current URL's server when it is the reason we are blocked, - * otherwise on the earliest active backoff. If no servers are in backoff, - * concludes with failure. Otherwise resets the CardPictureToLoad indices and - * retries after the backoff period. - */ - void scheduleDeferredRetry(); - private slots: /** @brief Updates the picDownload setting when it changes. */ void picDownloadChanged(); From eaf20da74754a28fffd6536a8c8a1d71aa7c3c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Mon, 21 Sep 2026 17:13:06 +0200 Subject: [PATCH 2/2] [PictureLoader] Hand backed-off entries back across the whole dispatch tick Review fixes on the 429-backoff skip: - Hand a backed-off queue entry back to its worker via scheduleDeferredRetry(host) instead of startNextPicDownload(), which was looping on the pre-redirect host every 100ms tick whenever the queued URL was a cached-redirect target whose redirect host was the one in backoff. - Keep scanning the queue after a hand-back (--i; continue) so a backed-off host at the head no longer consumes the entire dispatch tick, stalling every healthy host further down. - Emit imageRequestSucceeded(url) before makeRequest()'s cached-redirect backoff return so the status bar reclaims the deferred URL's widget instead of inflating the progress bar forever. - Only spend a per-second allowance when makeRequest() actually issues a request: it returns nullptr when the redirect target is backed off and the work is handed back, so the slot would otherwise be wasted on a no-op. - Gate the dispatch-time backoff skip on requestTouchesNetwork() so entries served straight from the disk cache (e.g. with downloads disabled) are not bounced into a 30-60s deferred retry for a host that 429'd earlier. --- .../card_picture_loader_worker.cpp | 41 ++++++++++++++----- .../card_picture_loader_worker.h | 4 ++ .../card_picture_loader_worker_work.h | 4 +- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index d8779863e..93ce6c96f 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -107,6 +107,9 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Check for cached redirects QUrl cachedRedirect = getCachedRedirect(url); if (!cachedRedirect.isEmpty()) { + // The status bar still needs to reclaim this URL's widget even when we hand the request back + // for a deferred retry instead of dispatching it onto the network. + emit imageRequestSucceeded(url); // The redirect target is a different host, which may itself be in 429 backoff; hand the // entry back to its worker so it waits the backoff out instead of dispatching straight // onto the backed-off host. @@ -115,7 +118,6 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture worker->scheduleDeferredRetry(cachedRedirect.host()); return nullptr; } - emit imageRequestSucceeded(url); return makeRequest(cachedRedirect, worker); } @@ -126,10 +128,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Cached entries are served straight from the disk cache even when picture downloads are // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a // genuine cache miss goes to the network, and only when downloads are enabled. - bool useNetworkCache = static_cast( - SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && - (cache->metaData(url).isValid() || !picDownload); + bool useNetworkCache = !requestTouchesNetwork(url); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); @@ -226,11 +225,19 @@ bool CardPictureLoaderWorker::processSingleRequest() const QString host = request.first.host(); // Don't dispatch requests to a host that is currently in its 429 backoff; hand the entry // back to its worker so it can wait the backoff out or fall through to another source, - // instead of leaving it parked in the queue with no reply pending. - if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + // instead of leaving it parked in the queue with no reply pending. Only applies to + // requests that will actually touch the network: one that will be served from the disk + // cache costs nothing and shouldn't wait out the 429. + if (requestTouchesNetwork(request.first) && + CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + // The queued URL is usually a cached-redirect target whose host differs from + // cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the + // blocked host's deadline) is used instead of startNextPicDownload() looping on the + // original host. Keep scanning so one backed-off entry doesn't monopolize the tick. auto entry = requestLoadQueue.takeAt(i); - entry.second->startNextPicDownload(); - return true; + --i; + entry.second->scheduleDeferredRetry(host); + continue; } // Seed the allowance now so a host that was rate limited gets its reduced // allowance instead of a fresh full quota mid-second. @@ -239,15 +246,27 @@ bool CardPictureLoaderWorker::processSingleRequest() } int allowance = hostQuotaRemaining.value(host); if (allowance > 0) { - hostQuotaRemaining.insert(host, allowance - 1); auto entry = requestLoadQueue.takeAt(i); - makeRequest(entry.first, entry.second); + // The allowance is only spent when a request is actually issued: makeRequest() returns + // nullptr when the cached redirect target is in backoff and it hands the entry back. + if (makeRequest(entry.first, entry.second)) { + hostQuotaRemaining.insert(host, allowance - 1); + } return true; } } return false; } +bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const +{ + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + (cache->metaData(url).isValid() || !picDownload); + return !useNetworkCache; +} + void CardPictureLoaderWorker::onHostRateLimited(const QString &host) { hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2)); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h index 2a13e847c..c6a93cb1c 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h @@ -133,6 +133,10 @@ private: /** @brief Returns cached redirect URL for the given original URL, if available. */ [[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const; + /** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk + * cache. */ + [[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const; + /** @brief Loads redirect cache from disk. */ void loadRedirectCache(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index c5aa07d10..b5cd57af8 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -51,9 +51,7 @@ public: * @brief Starts downloading the next URL for this card. * * Skips URLs whose server is currently in 429 backoff, either waiting the - * backoff out or falling through to the other configured sources. Also used by - * the dispatch machinery to hand an entry back after it was removed from the - * request queue when its host turned out to be backed off. + * backoff out or falling through to the other configured sources. */ void startNextPicDownload();