porymap/src/core/network.cpp
2024-02-05 11:54:35 -05:00

155 lines
6.4 KiB
C++

#include "network.h"
#include "config.h"
#include <QCoreApplication>
#include <QRegularExpression>
#include <QTimer>
// Fallback wait time (in seconds) for rate limiting
static const int DefaultWaitTime = 120;
NetworkAccessManager::NetworkAccessManager(QObject * parent) : QNetworkAccessManager(parent) {
// We store rate limit end times in the user's config so that Porymap will still respect them after a restart.
// To avoid reading/writing to a local file during network operations, we only read/write the file when the
// manager is created/destroyed respectively.
this->rateLimitTimes = porymapConfig.getRateLimitTimes();
this->setRedirectPolicy(QNetworkRequest::NoLessSafeRedirectPolicy);
};
NetworkAccessManager::~NetworkAccessManager() {
porymapConfig.setRateLimitTimes(this->rateLimitTimes);
qDeleteAll(this->cache);
}
const QNetworkRequest NetworkAccessManager::getRequest(const QUrl &url) {
QNetworkRequest request(url);
// Set User-Agent to porymap/#.#.#
request.setHeader(QNetworkRequest::UserAgentHeader, QString("%1/%2").arg(QCoreApplication::applicationName())
.arg(QCoreApplication::applicationVersion()));
// If we've made a successful request in this session already, set the If-None-Match header.
// We'll only get a full response from the server if the data has changed since this last request.
// This helps to avoid hitting rate limits.
auto cacheEntry = this->cache.value(url, nullptr);
if (cacheEntry)
request.setHeader(QNetworkRequest::IfNoneMatchHeader, cacheEntry->eTag);
return request;
}
NetworkReplyData * NetworkAccessManager::get(const QString &url) {
return this->get(QUrl(url));
}
NetworkReplyData * NetworkAccessManager::get(const QUrl &url) {
NetworkReplyData * data = new NetworkReplyData();
data->m_url = url;
// If we are rate-limited, don't send a new request.
if (this->rateLimitTimes.contains(url)) {
auto time = this->rateLimitTimes.value(url);
if (!time.isNull() && time > QDateTime::currentDateTime()) {
data->m_retryAfter = time;
data->m_error = QString("Rate limit reached. Please try again after %1.").arg(data->m_retryAfter.toString());
QTimer::singleShot(1000, data, &NetworkReplyData::finish); // We can't emit this signal before caller has a chance to connect
return data;
}
// Rate limiting expired
this->rateLimitTimes.remove(url);
}
QNetworkReply * reply = QNetworkAccessManager::get(this->getRequest(url));
connect(reply, &QNetworkReply::finished, [this, reply, data] {
this->processReply(reply, data);
data->finish();
});
return data;
}
void NetworkAccessManager::processReply(QNetworkReply * reply, NetworkReplyData * data) {
if (!reply || !reply->isFinished())
return;
// The url in the request and the url ultimately processed (reply->url()) may differ if the request was redirected.
// For identification purposes (e.g. knowing if we are rate limited before a request is made) we use the url that
// was originally given for the request.
auto url = data->m_url;
reply->deleteLater();
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
// Handle pagination (specifically, the format GitHub uses).
// This header is still sent for a 304, so we don't need to bother caching it.
if (reply->hasRawHeader("link")) {
static const QRegularExpression regex("<(?<url>.+)?>; rel=\"next\"");
QRegularExpressionMatch match = regex.match(QString(reply->rawHeader("link")));
if (match.hasMatch())
data->m_nextUrl = QUrl(match.captured("url"));
}
if (statusCode == 304) {
// "Not Modified", data hasn't changed since our last request.
auto cacheEntry = this->cache.value(url, nullptr);
if (cacheEntry)
data->m_body = cacheEntry->data;
else
data->m_error = "Failed to read webpage from cache.";
return;
}
// Handle standard rate limit header
if (reply->hasRawHeader("retry-after")) {
auto retryAfter = QVariant(reply->rawHeader("retry-after"));
if (retryAfter.canConvert<QDateTime>()) {
data->m_retryAfter = retryAfter.toDateTime().toLocalTime();
} else if (retryAfter.canConvert<int>()) {
data->m_retryAfter = QDateTime::currentDateTime().addSecs(retryAfter.toInt());
}
if (data->m_retryAfter.isNull() || data->m_retryAfter <= QDateTime::currentDateTime()) {
data->m_retryAfter = QDateTime::currentDateTime().addSecs(DefaultWaitTime);
}
if (statusCode == 429) {
data->m_error = "Too many requests. ";
} else if (statusCode == 503) {
data->m_error = "Service busy or unavailable. ";
}
data->m_error.append(QString("Please try again after %1.").arg(data->m_retryAfter.toString()));
this->rateLimitTimes.insert(url, data->m_retryAfter);
return;
}
// Handle GitHub's rate limit headers. As of writing this is (without authentication) 60 requests per IP address per hour.
bool ok;
int limitRemaining = reply->rawHeader("x-ratelimit-remaining").toInt(&ok);
if (ok && limitRemaining <= 0) {
auto limitReset = reply->rawHeader("x-ratelimit-reset").toLongLong(&ok);
data->m_retryAfter = ok ? QDateTime::fromSecsSinceEpoch(limitReset).toLocalTime()
: QDateTime::currentDateTime().addSecs(DefaultWaitTime);;
data->m_error = QString("Too many requests. Please try again after %1.").arg(data->m_retryAfter.toString());
this->rateLimitTimes.insert(url, data->m_retryAfter);
return;
}
// Handle remaining errors generically
auto error = reply->error();
if (error != QNetworkReply::NoError) {
data->m_error = reply->errorString();
return;
}
// Successful reply, we've received new data. Insert this data in the cache.
CacheEntry * cacheEntry = this->cache.value(url, nullptr);
if (!cacheEntry) {
cacheEntry = new CacheEntry;
this->cache.insert(url, cacheEntry);
}
auto eTagHeader = reply->header(QNetworkRequest::ETagHeader);
if (eTagHeader.canConvert<QString>())
cacheEntry->eTag = eTagHeader.toString();
cacheEntry->data = data->m_body = reply->readAll();
}