From ad159b5206de615762e22f95e97ae61400f11761 Mon Sep 17 00:00:00 2001 From: Kitsune Ral Date: Tue, 20 Aug 2019 19:56:54 +0900 Subject: BaseJob: Status/StatusCode tweaks, cleanup, mo' comments Notably, recovered Status::fromHttpCode() that was introduced in 5722ceaf4bd10c29f1091e3dc5a87f5650ea8c71 but fell victim of a careless merge (so much for introducing non-topical changes in feature branches). --- lib/jobs/basejob.cpp | 135 ++++++++++++++++++++++++++++----------------------- lib/jobs/basejob.h | 37 ++++++++------ 2 files changed, 97 insertions(+), 75 deletions(-) (limited to 'lib/jobs') diff --git a/lib/jobs/basejob.cpp b/lib/jobs/basejob.cpp index f3ba00b5..621762be 100644 --- a/lib/jobs/basejob.cpp +++ b/lib/jobs/basejob.cpp @@ -77,6 +77,8 @@ public: QByteArray rawResponse; QUrl errorUrl; //< May contain a URL to help with some errors + LoggingCategory logCat = JOBS; + QTimer timer; QTimer retryTimer; @@ -86,7 +88,12 @@ public: int maxRetries = errorStrategy.size(); int retriesTaken = 0; - LoggingCategory logCat = JOBS; + QString urlForLog() const + { + return reply + ? reply->url().toString(QUrl::RemoveQuery) + : makeRequestUrl(connection->baseUrl(), apiEndpoint).toString(); + } }; BaseJob::BaseJob(HttpVerb verb, const QString& name, const QString& endpoint, @@ -112,7 +119,7 @@ BaseJob::~BaseJob() QUrl BaseJob::requestUrl() const { - return d->reply ? d->reply->request().url() : QUrl(); + return d->reply ? d->reply->url() : QUrl(); } bool BaseJob::isBackground() const @@ -193,17 +200,13 @@ void BaseJob::Private::sendRequest(bool inBackground) req.setRawHeader("Authorization", QByteArray("Bearer ") + connection->accessToken()); req.setAttribute(QNetworkRequest::BackgroundRequestAttribute, inBackground); -#if (QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)) req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); req.setMaximumRedirectsAllowed(10); -#endif req.setAttribute(QNetworkRequest::HttpPipeliningAllowedAttribute, true); -#if QT_VERSION >= QT_VERSION_CHECK(5, 9, 0) - // some sources claim that there are issues with QT 5.8 req.setAttribute(QNetworkRequest::HTTP2AllowedAttribute, true); -#endif for (auto it = requestHeaders.cbegin(); it != requestHeaders.cend(); ++it) req.setRawHeader(it.key(), it.value()); + switch (verb) { case HttpVerb::Get: reply.reset(connection->nam()->get(req)); @@ -318,6 +321,47 @@ bool checkContentType(const QByteArray& type, const QByteArrayList& patterns) return false; } +BaseJob::Status BaseJob::Status::fromHttpCode(int httpCode, QString msg) +{ + // clang-format off + return { [httpCode]() -> StatusCode { + if (httpCode / 10 == 41) // 41x errors + return httpCode == 410 ? IncorrectRequestError : NotFoundError; + switch (httpCode) { + case 401: case 403: case 407: + return ContentAccessError; + case 404: + return NotFoundError; + case 400: case 405: case 406: case 426: case 428: case 505: + case 494: // Unofficial nginx "Request header too large" + case 497: // Unofficial nginx "HTTP request sent to HTTPS port" + return IncorrectRequestError; + case 429: + return TooManyRequestsError; + case 501: case 510: + return RequestNotImplementedError; + case 511: + return NetworkAuthRequiredError; + default: + return NetworkError; + } + }(), std::move(msg) }; + // clang-format on +} + +QDebug BaseJob::Status::dumpToLog(QDebug dbg) const +{ + QDebugStateSaver _s(dbg); + dbg.noquote().nospace(); + if (auto* const k = QMetaEnum::fromType().valueToKey(code)) { + const QByteArray b = k; + dbg << b.mid(b.lastIndexOf(':')); + } else + dbg << code; + return dbg << ": " << message; + +} + BaseJob::Status BaseJob::doCheckReply(QNetworkReply* reply) const { // QNetworkReply error codes seem to be flawed when it comes to HTTP; @@ -327,62 +371,30 @@ BaseJob::Status BaseJob::doCheckReply(QNetworkReply* reply) const const auto httpCodeHeader = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute); if (!httpCodeHeader.isValid()) { - qCWarning(d->logCat) << this << "didn't get valid HTTP headers"; + qCWarning(d->logCat) << "No valid HTTP headers from" << d->urlForLog(); return { NetworkError, reply->errorString() }; } - const QString replyState = reply->isRunning() - ? QStringLiteral("(tentative)") - : QStringLiteral("(final)"); - const auto urlString = '|' + d->reply->url().toDisplayString(); const auto httpCode = httpCodeHeader.toInt(); - const auto reason = - reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute).toString(); if (httpCode / 100 == 2) // 2xx { - qCDebug(d->logCat).noquote().nospace() << this << urlString; - qCDebug(d->logCat).noquote() << " " << httpCode << reason << replyState; + if (reply->isFinished()) + qCInfo(d->logCat) << httpCode << "<-" << d->urlForLog(); if (!checkContentType(reply->rawHeader("Content-Type"), d->expectedContentTypes)) return { UnexpectedResponseTypeWarning, "Unexpected content type of the response" }; return NoError; } + if (reply->isFinished()) + qCWarning(d->logCat) << httpCode << "<-" << d->urlForLog(); - qCWarning(d->logCat).noquote().nospace() << this << urlString; - qCWarning(d->logCat).noquote() << " " << httpCode << reason << replyState; - return { [httpCode]() -> StatusCode { - if (httpCode / 10 == 41) - return httpCode == 410 ? IncorrectRequestError - : NotFoundError; - switch (httpCode) { - case 401: - case 403: - case 407: - return ContentAccessError; - case 404: - return NotFoundError; - case 400: - case 405: - case 406: - case 426: - case 428: - case 505: - case 494: // Unofficial nginx "Request header too large" - case 497: // Unofficial nginx "HTTP request sent to HTTPS port" - return IncorrectRequestError; - case 429: - return TooManyRequestsError; - case 501: - case 510: - return RequestNotImplementedError; - case 511: - return NetworkAuthRequiredError; - default: - return NetworkError; - } - }(), - reply->errorString() }; + auto message = reply->errorString(); + if (message.isEmpty()) + message = reply->attribute(QNetworkRequest::HttpReasonPhraseAttribute) + .toString(); + + return Status::fromHttpCode(httpCode, message); } BaseJob::Status BaseJob::parseReply(QNetworkReply* reply) @@ -398,7 +410,7 @@ BaseJob::Status BaseJob::parseReply(QNetworkReply* reply) BaseJob::Status BaseJob::parseJson(const QJsonDocument&) { return Success; } -BaseJob::Status BaseJob::parseError(QNetworkReply* reply, +BaseJob::Status BaseJob::parseError(QNetworkReply* /*reply*/, const QJsonObject& errorJson) { const auto errCode = errorJson.value("errcode"_ls).toString(); @@ -441,7 +453,7 @@ BaseJob::Status BaseJob::parseError(QNetworkReply* reply, void BaseJob::stop() { - // This method is used to semi-finalise the job before retrying; so + // This method is (also) used to semi-finalise the job before retrying; so // stop the timeout timer but keep the retry timer running. d->timer.stop(); if (d->reply) { @@ -449,7 +461,7 @@ void BaseJob::stop() if (d->reply->isRunning()) { qCWarning(d->logCat) << this << "stopped without ready network reply"; - d->reply->abort(); + d->reply->abort(); // Keep the reply object in case clients need it } } else qCWarning(d->logCat) << this << "stopped with empty network reply"; @@ -474,10 +486,10 @@ void BaseJob::finishJob() return; } - // Notify those interested in any completion of the job (including killing) + // Notify those interested in any completion of the job including abandon() emit finished(this); - emit result(this); + emit result(this); // abandon() doesn't emit this if (error()) emit failure(this); else @@ -582,21 +594,22 @@ void BaseJob::setStatus(Status s) { // The crash that led to this code has been reported in // https://github.com/quotient-im/Quaternion/issues/566 - basically, - // when cleaning up childrent of a deleted Connection, there's a chance + // when cleaning up children of a deleted Connection, there's a chance // of pending jobs being abandoned, calling setStatus(Abandoned). // There's nothing wrong with this; however, the safety check for // cleartext access tokens below uses d->connection - which is a dangling // pointer. // To alleviate that, a stricter condition is applied, that for Abandoned // and to-be-Abandoned jobs the status message will be disregarded entirely. - // For 0.6 we might rectify the situation by making d->connection - // a QPointer<> (and derive ConnectionData from QObject, respectively). - if (d->status.code == Abandoned || s.code == Abandoned) - s.message.clear(); - + // We could rectify the situation by making d->connection a QPointer<> + // (and deriving ConnectionData from QObject, respectively) but it's + // a too edge case for the hassle. if (d->status == s) return; + if (d->status.code == Abandoned || s.code == Abandoned) + s.message.clear(); + if (!s.message.isEmpty() && d->connection && !d->connection->accessToken().isEmpty()) s.message.replace(d->connection->accessToken(), "(REDACTED)"); diff --git a/lib/jobs/basejob.h b/lib/jobs/basejob.h index fd7beca0..9de7b49d 100644 --- a/lib/jobs/basejob.h +++ b/lib/jobs/basejob.h @@ -24,6 +24,7 @@ #include #include #include +#include class QNetworkReply; class QSslError; @@ -43,18 +44,23 @@ class BaseJob : public QObject { Q_PROPERTY(QUrl requestUrl READ requestUrl CONSTANT) Q_PROPERTY(int maxRetries READ maxRetries WRITE setMaxRetries) public: + /*! The status code of a job + * + * Every job is created in Unprepared status; upon calling prepare() + * from Connection (if things are fine) it go to Pending status. After + * that, the next transition comes after the reply arrives and its contents + * are analysed. At any point in time the job can be abandon()ed, causing + * it to switch to status Abandoned for a brief period before deletion. + */ enum StatusCode { - NoError = 0 // To be compatible with Qt conventions - , Success = 0, + NoError = Success, // To be compatible with Qt conventions Pending = 1, - WarningLevel = 20, + WarningLevel = 20, //< Warnings have codes starting from this UnexpectedResponseType = 21, UnexpectedResponseTypeWarning = UnexpectedResponseType, - Abandoned = 50 //< A tiny period between abandoning and object deletion - , - ErrorLevel = 100 //< Errors have codes starting from this - , + Abandoned = 50, //< A tiny period between abandoning and object deletion + ErrorLevel = 100, //< Errors have codes starting from this NetworkError = 100, Timeout, TimeoutError = Timeout, @@ -64,10 +70,12 @@ public: IncorrectRequestError = IncorrectRequest, IncorrectResponse, IncorrectResponseError = IncorrectResponse, - JsonParseError //< deprecated; Use IncorrectResponse instead + JsonParseError + Q_DECL_ENUMERATOR_DEPRECATED_X("Use IncorrectResponse instead") = IncorrectResponse, TooManyRequests, TooManyRequestsError = TooManyRequests, + RateLimited = TooManyRequests, RequestNotImplemented, RequestNotImplementedError = RequestNotImplemented, UnsupportedRoomVersion, @@ -80,6 +88,7 @@ public: UserDeactivated, UserDefinedError = 256 }; + Q_ENUM(StatusCode) /** * A simple wrapper around QUrlQuery that allows its creation from @@ -97,7 +106,7 @@ public: using Data = RequestData; - /** + /*! * This structure stores the status of a server call job. The status * consists of a code, that is described (but not delimited) by the * respective enum, and a freeform message. @@ -106,16 +115,16 @@ public: * along the lines of StatusCode, with additional values * starting at UserDefinedError */ - class Status { - public: + struct Status { Status(StatusCode c) : code(c) {} Status(int c, QString m) : code(c), message(std::move(m)) {} + static Status fromHttpCode(int httpCode, QString msg = {}); bool good() const { return code < ErrorLevel; } - friend QDebug operator<<(QDebug dbg, const Status& s) + QDebug dumpToLog(QDebug dbg) const; + friend QDebug operator<<(const QDebug& dbg, const Status& s) { - QDebugStateSaver _s(dbg); - return dbg.noquote().nospace() << s.code << ": " << s.message; + return s.dumpToLog(dbg); } bool operator==(const Status& other) const -- cgit v1.2.3 From 59c4996a602e9eeae4e3bfc0210ff15f957df38f Mon Sep 17 00:00:00 2001 From: Kitsune Ral Date: Tue, 20 Aug 2019 20:09:09 +0900 Subject: BaseJob/ConnectionData: connection-wide rate-limiting As before, completely transparent for clients, driven by 529 errors from the server (but cases of rate limiting are signalled by BaseJob::rateLimited). That brings changes to BaseJob API: timeouts now use int64_t and also can be handled in std::chrono terms; aboutToStart() -> aboutToSendRequest(); started() -> sentRequest(). Closes #292. --- lib/connection.cpp | 2 +- lib/connectiondata.cpp | 70 ++++++++++++++++++- lib/connectiondata.h | 6 ++ lib/jobs/basejob.cpp | 159 ++++++++++++++++++++++++------------------- lib/jobs/basejob.h | 63 +++++++++++------ lib/jobs/downloadfilejob.cpp | 4 +- lib/jobs/downloadfilejob.h | 4 +- lib/room.cpp | 2 +- 8 files changed, 212 insertions(+), 98 deletions(-) (limited to 'lib/jobs') diff --git a/lib/connection.cpp b/lib/connection.cpp index 5f90ed55..5ebdcf6c 100644 --- a/lib/connection.cpp +++ b/lib/connection.cpp @@ -1358,7 +1358,7 @@ void Connection::setLazyLoading(bool newValue) void Connection::run(BaseJob* job, RunningPolicy runningPolicy) const { connect(job, &BaseJob::failure, this, &Connection::requestFailed); - job->start(d->data.get(), runningPolicy & BackgroundRequest); + job->prepare(d->data.get(), runningPolicy & BackgroundRequest); d->data->submit(job); } diff --git a/lib/connectiondata.cpp b/lib/connectiondata.cpp index 41d97b87..e241e376 100644 --- a/lib/connectiondata.cpp +++ b/lib/connectiondata.cpp @@ -20,11 +20,21 @@ #include "logging.h" #include "networkaccessmanager.h" +#include "jobs/basejob.h" + +#include +#include + +#include using namespace Quotient; -struct ConnectionData::Private { - explicit Private(QUrl url) : baseUrl(std::move(url)) {} +class ConnectionData::Private { +public: + explicit Private(QUrl url) : baseUrl(std::move(url)) + { + rateLimiter.setSingleShot(true); + } QUrl baseUrl; QByteArray accessToken; @@ -34,14 +44,68 @@ struct ConnectionData::Private { mutable unsigned int txnCounter = 0; const qint64 txnBase = QDateTime::currentMSecsSinceEpoch(); + + QString id() const { return userId + '/' + deviceId; } + + using job_queue_t = std::queue>; + std::array jobs; // 0 - foreground, 1 - background + QTimer rateLimiter; }; ConnectionData::ConnectionData(QUrl baseUrl) : d(std::make_unique(std::move(baseUrl))) -{} +{ + // Each lambda invocation below takes no more than one job from the + // queues (first foreground, then background) and resumes it; then + // restarts the rate limiter timer with duration 0, effectively yielding + // to the event loop and then resuming until both queues are empty. + QObject::connect(&d->rateLimiter, &QTimer::timeout, [this] { + // TODO: Consider moving out all job->sendRequest() invocations to + // a dedicated thread + d->rateLimiter.setInterval(0); + for (auto& q : d->jobs) + while (!q.empty()) { + auto& job = q.front(); + q.pop(); + if (!job || job->error() == BaseJob::Abandoned) + continue; + if (job->error() != BaseJob::Pending) { + qCCritical(MAIN) + << "Job" << job + << "is in the wrong status:" << job->status(); + Q_ASSERT(false); + job->setStatus(BaseJob::Pending); + } + job->sendRequest(); + d->rateLimiter.start(); + return; + } + qCDebug(MAIN) << d->id() << "job queues are empty"; + }); +} ConnectionData::~ConnectionData() = default; +void ConnectionData::submit(BaseJob* job) +{ + Q_ASSERT(job->error() == BaseJob::Pending); + if (!d->rateLimiter.isActive()) { + job->sendRequest(); + return; + } + d->jobs[size_t(job->isBackground())].emplace(job); + qCDebug(MAIN) << job << "queued," << d->jobs.front().size() << "+" + << d->jobs.back().size() << "total jobs in" << d->id() + << "queues"; +} + +void ConnectionData::limitRate(std::chrono::milliseconds nextCallAfter) +{ + qCDebug(MAIN) << "Jobs for" << (d->userId + "/" + d->deviceId) + << "suspended for" << nextCallAfter.count() << "ms"; + d->rateLimiter.start(nextCallAfter); +} + QByteArray ConnectionData::accessToken() const { return d->accessToken; } QUrl ConnectionData::baseUrl() const { return d->baseUrl; } diff --git a/lib/connectiondata.h b/lib/connectiondata.h index 561893df..5cd7c3c7 100644 --- a/lib/connectiondata.h +++ b/lib/connectiondata.h @@ -21,15 +21,21 @@ #include #include +#include class QNetworkAccessManager; namespace Quotient { +class BaseJob; + class ConnectionData { public: explicit ConnectionData(QUrl baseUrl); virtual ~ConnectionData(); + void submit(BaseJob* job); + void limitRate(std::chrono::milliseconds nextCallAfter); + QByteArray accessToken() const; QUrl baseUrl() const; const QString& deviceId() const; diff --git a/lib/jobs/basejob.cpp b/lib/jobs/basejob.cpp index 621762be..0a17431c 100644 --- a/lib/jobs/basejob.cpp +++ b/lib/jobs/basejob.cpp @@ -31,6 +31,8 @@ #include using namespace Quotient; +using std::chrono::seconds, std::chrono::milliseconds; +using std::chrono_literals::operator""s; struct NetworkReplyDeleter : public QScopedPointerDeleteLater { static inline void cleanup(QNetworkReply* reply) @@ -43,6 +45,11 @@ struct NetworkReplyDeleter : public QScopedPointerDeleteLater { class BaseJob::Private { public: + struct JobTimeoutConfig { + seconds jobTimeout; + seconds nextRetryInterval; + }; + // Using an idiom from clang-tidy: // http://clang.llvm.org/extra/clang-tidy/checks/modernize-pass-by-value.html Private(HttpVerb v, QString endpoint, const QUrlQuery& q, Data&& data, @@ -52,12 +59,14 @@ public: , requestQuery(q) , requestData(std::move(data)) , needsToken(nt) - {} + { + timer.setSingleShot(true); + retryTimer.setSingleShot(true); + } - void sendRequest(bool inBackground); - const JobTimeoutConfig& getCurrentTimeoutConfig() const; + void sendRequest(); - const ConnectionData* connection = nullptr; + ConnectionData* connection = nullptr; // Contents for the network request HttpVerb verb; @@ -67,13 +76,15 @@ public: Data requestData; bool needsToken; + bool inBackground = false; + // There's no use of QMimeType here because we don't want to match // content types against the known MIME type hierarchy; and at the same // type QMimeType is of little help with MIME type globs (`text/*` etc.) - QByteArrayList expectedContentTypes; + QByteArrayList expectedContentTypes { "application/json" }; QScopedPointer reply; - Status status = Pending; + Status status = Unprepared; QByteArray rawResponse; QUrl errorUrl; //< May contain a URL to help with some errors @@ -82,12 +93,18 @@ public: QTimer timer; QTimer retryTimer; - QVector errorStrategy = { { 90, 5 }, - { 90, 10 }, - { 120, 30 } }; - int maxRetries = errorStrategy.size(); + static constexpr std::array errorStrategy { + { { 90s, 5s }, { 90s, 10s }, { 120s, 30s } } + }; + int maxRetries = int(errorStrategy.size()); int retriesTaken = 0; + const JobTimeoutConfig& getCurrentTimeoutConfig() const + { + return errorStrategy[std::min(size_t(retriesTaken), + errorStrategy.size() - 1)]; + } + QString urlForLog() const { return reply @@ -106,9 +123,11 @@ BaseJob::BaseJob(HttpVerb verb, const QString& name, const QString& endpoint, : d(new Private(verb, endpoint, query, std::move(data), needsToken)) { setObjectName(name); - setExpectedContentTypes({ "application/json" }); - d->timer.setSingleShot(true); connect(&d->timer, &QTimer::timeout, this, &BaseJob::timeout); + connect(&d->retryTimer, &QTimer::timeout, this, [this] { + setStatus(Pending); + sendRequest(); + }); } BaseJob::~BaseJob() @@ -124,10 +143,7 @@ QUrl BaseJob::requestUrl() const bool BaseJob::isBackground() const { - return d->reply - && d->reply->request() - .attribute(QNetworkRequest::BackgroundRequestAttribute) - .toBool(); + return d->inBackground; } const QString& BaseJob::apiEndpoint() const { return d->apiEndpoint; } @@ -191,7 +207,7 @@ QUrl BaseJob::makeRequestUrl(QUrl baseUrl, const QString& path, return baseUrl; } -void BaseJob::Private::sendRequest(bool inBackground) +void BaseJob::Private::sendRequest() { QNetworkRequest req { makeRequestUrl(connection->baseUrl(), apiEndpoint, requestQuery) }; @@ -223,36 +239,31 @@ void BaseJob::Private::sendRequest(bool inBackground) } } -void BaseJob::beforeStart(const ConnectionData*) {} +void BaseJob::doPrepare() {} -void BaseJob::afterStart(const ConnectionData*, QNetworkReply*) {} +void BaseJob::onSentRequest(QNetworkReply*) {} void BaseJob::beforeAbandon(QNetworkReply*) {} -void BaseJob::start(const ConnectionData* connData, bool inBackground) +void BaseJob::prepare(ConnectionData* connData, bool inBackground) { + d->inBackground = inBackground; d->connection = connData; - d->retryTimer.setSingleShot(true); - connect(&d->retryTimer, &QTimer::timeout, this, - [this, inBackground] { sendRequest(inBackground); }); - - beforeStart(connData); - if (status().good()) - sendRequest(inBackground); - if (status().good()) - afterStart(connData, d->reply.data()); - if (!status().good()) + doPrepare(); + if (status().code != Unprepared && status().code != Pending) QTimer::singleShot(0, this, &BaseJob::finishJob); + setStatus(Pending); } -void BaseJob::sendRequest(bool inBackground) +void BaseJob::sendRequest() { - emit aboutToStart(); - d->retryTimer.stop(); // In case we were counting down at the moment - qCDebug(d->logCat) << this << "sending request to" << d->apiEndpoint; - if (!d->requestQuery.isEmpty()) - qCDebug(d->logCat) << " query:" << d->requestQuery.toString(); - d->sendRequest(inBackground); + if (status().code == Abandoned) + return; + Q_ASSERT(d->connection && status().code == Pending); + qCDebug(d->logCat) << "Making request to" << d->urlForLog(); + emit aboutToSendRequest(); + d->sendRequest(); + Q_ASSERT(d->reply); connect(d->reply.data(), &QNetworkReply::finished, this, &BaseJob::gotReply); if (d->reply->isRunning()) { connect(d->reply.data(), &QNetworkReply::metaDataChanged, this, @@ -262,10 +273,12 @@ void BaseJob::sendRequest(bool inBackground) connect(d->reply.data(), &QNetworkReply::downloadProgress, this, &BaseJob::downloadProgress); d->timer.start(getCurrentTimeout()); - qCDebug(d->logCat) << this << "request has been sent"; - emit started(); + qCInfo(d->logCat).noquote() << "Request sent to" << d->urlForLog(); + onSentRequest(d->reply.data()); + emit sentRequest(); } else - qCWarning(d->logCat) << this << "request could not start"; + qCWarning(d->logCat).noquote() + << "Request could not start:" << d->urlForLog(); } void BaseJob::checkReply() { setStatus(doCheckReply(d->reply.data())); } @@ -286,13 +299,7 @@ void BaseJob::gotReply() parseError(d->reply.data(), QJsonDocument::fromJson(d->rawResponse).object())); } - - if (status().code != TooManyRequestsError) - finishJob(); - else { - stop(); - emit retryScheduled(d->retriesTaken, d->retryTimer.interval()); - } + finishJob(); } bool checkContentType(const QByteArray& type, const QByteArrayList& patterns) @@ -416,14 +423,13 @@ BaseJob::Status BaseJob::parseError(QNetworkReply* /*reply*/, const auto errCode = errorJson.value("errcode"_ls).toString(); if (error() == TooManyRequestsError || errCode == "M_LIMIT_EXCEEDED") { QString msg = tr("Too many requests"); - auto retryInterval = errorJson.value("retry_after_ms"_ls).toInt(-1); - if (retryInterval != -1) - msg += tr(", next retry advised after %1 ms").arg(retryInterval); + int64_t retryAfterMs = errorJson.value("retry_after_ms"_ls).toInt(-1); + if (retryAfterMs >= 0) + msg += tr(", next retry advised after %1 ms").arg(retryAfterMs); else // We still have to figure some reasonable interval - retryInterval = getNextRetryInterval(); + retryAfterMs = getNextRetryMs(); - qCWarning(d->logCat) << this << "will retry in" << retryInterval << "ms"; - d->retryTimer.start(retryInterval); + d->connection->limitRate(milliseconds(retryAfterMs)); return { TooManyRequestsError, msg }; } @@ -470,19 +476,23 @@ void BaseJob::stop() void BaseJob::finishJob() { stop(); - if ((error() == NetworkError || error() == TimeoutError) + if (error() == TooManyRequests) { + emit rateLimited(); + setStatus(Pending); + d->connection->submit(this); + return; + } + if ((error() == NetworkError || error() == Timeout) && d->retriesTaken < d->maxRetries) { - // TODO: The whole retrying thing should be put to ConnectionManager + // TODO: The whole retrying thing should be put to Connection(Manager) // otherwise independently retrying jobs make a bit of notification // storm towards the UI. - const auto retryInterval = error() == TimeoutError - ? 0 - : getNextRetryInterval(); + const seconds retryIn = error() == Timeout ? 0s : getNextRetryInterval(); ++d->retriesTaken; qCWarning(d->logCat).nospace() << this << ": retry #" << d->retriesTaken - << " in " << retryInterval / 1000 << " s"; - d->retryTimer.start(retryInterval); - emit retryScheduled(d->retriesTaken, retryInterval); + << " in " << retryIn.count() << " s"; + d->retryTimer.start(retryIn); + emit retryScheduled(d->retriesTaken, milliseconds(retryIn).count()); return; } @@ -498,24 +508,35 @@ void BaseJob::finishJob() deleteLater(); } -const JobTimeoutConfig& BaseJob::Private::getCurrentTimeoutConfig() const +seconds BaseJob::getCurrentTimeout() const +{ + return d->getCurrentTimeoutConfig().jobTimeout; +} + +BaseJob::duration_ms_t BaseJob::getCurrentTimeoutMs() const +{ + return milliseconds(getCurrentTimeout()).count(); +} + +seconds BaseJob::getNextRetryInterval() const { - return errorStrategy[std::min(retriesTaken, errorStrategy.size() - 1)]; + return d->getCurrentTimeoutConfig().nextRetryInterval; } -BaseJob::duration_t BaseJob::getCurrentTimeout() const +BaseJob::duration_ms_t BaseJob::getNextRetryMs() const { - return d->getCurrentTimeoutConfig().jobTimeout * 1000; + return milliseconds(getNextRetryInterval()).count(); } -BaseJob::duration_t BaseJob::getNextRetryInterval() const +milliseconds BaseJob::timeToRetry() const { - return d->getCurrentTimeoutConfig().nextRetryInterval * 1000; + return d->retryTimer.isActive() ? d->retryTimer.remainingTimeAsDuration() + : 0s; } -BaseJob::duration_t BaseJob::millisToRetry() const +BaseJob::duration_ms_t BaseJob::millisToRetry() const { - return d->retryTimer.isActive() ? d->retryTimer.remainingTime() : 0; + return timeToRetry().count(); } int BaseJob::maxRetries() const { return d->maxRetries; } diff --git a/lib/jobs/basejob.h b/lib/jobs/basejob.h index 9de7b49d..4dc287f8 100644 --- a/lib/jobs/basejob.h +++ b/lib/jobs/basejob.h @@ -34,11 +34,6 @@ class ConnectionData; enum class HttpVerb { Get, Put, Post, Delete }; -struct JobTimeoutConfig { - int jobTimeout; - int nextRetryInterval; -}; - class BaseJob : public QObject { Q_OBJECT Q_PROPERTY(QUrl requestUrl READ requestUrl CONSTANT) @@ -59,6 +54,7 @@ public: WarningLevel = 20, //< Warnings have codes starting from this UnexpectedResponseType = 21, UnexpectedResponseTypeWarning = UnexpectedResponseType, + Unprepared = 25, //< Initial job state is incomplete, hence warning level Abandoned = 50, //< A tiny period between abandoning and object deletion ErrorLevel = 100, //< Errors have codes starting from this NetworkError = 100, @@ -140,8 +136,6 @@ public: QString message; }; - using duration_t = int; // milliseconds - public: BaseJob(HttpVerb verb, const QString& name, const QString& endpoint, bool needsToken = true); @@ -153,13 +147,16 @@ public: /** Current status of the job */ Status status() const; + /** Short human-friendly message on the job status */ QString statusCaption() const; + /** Get raw response body as received from the server * \param bytesAtMost return this number of leftmost bytes, or -1 * to return the entire response */ QByteArray rawData(int bytesAtMost = -1) const; + /** Get UI-friendly sample of raw data * * This is almost the same as rawData but appends the "truncated" @@ -175,17 +172,24 @@ public: * \sa status */ int error() const; + /** Error-specific message, as returned by the server */ virtual QString errorString() const; + /** A URL to help/clarify the error, if provided by the server */ QUrl errorUrl() const; int maxRetries() const; void setMaxRetries(int newMaxRetries); - Q_INVOKABLE duration_t getCurrentTimeout() const; - Q_INVOKABLE duration_t getNextRetryInterval() const; - Q_INVOKABLE duration_t millisToRetry() const; + using duration_ms_t = std::chrono::milliseconds::rep; // normally int64_t + + std::chrono::seconds getCurrentTimeout() const; + Q_INVOKABLE duration_ms_t getCurrentTimeoutMs() const; + std::chrono::seconds getNextRetryInterval() const; + Q_INVOKABLE duration_ms_t getNextRetryMs() const; + std::chrono::milliseconds timeToRetry() const; + Q_INVOKABLE duration_ms_t millisToRetry() const; friend QDebug operator<<(QDebug dbg, const BaseJob* j) { @@ -193,7 +197,7 @@ public: } public slots: - void start(const ConnectionData* connData, bool inBackground = false); + void prepare(ConnectionData* connData, bool inBackground); /** * Abandons the result of this job, arrived or unarrived. @@ -206,10 +210,10 @@ public slots: signals: /** The job is about to send a network request */ - void aboutToStart(); + void aboutToSendRequest(); /** The job has sent a network request */ - void started(); + void sentRequest(); /** The job has changed its status */ void statusChanged(Status newStatus); @@ -222,7 +226,14 @@ signals: * @param inMilliseconds the interval after which the next attempt will be * taken */ - void retryScheduled(int nextAttempt, int inMilliseconds); + void retryScheduled(int nextAttempt, duration_ms_t inMilliseconds); + + /** + * The previous network request has been rate-limited; the next attempt + * will be queued and run sometime later. Since other jobs may already + * wait in the queue, it's not possible to predict the wait time. + */ + void rateLimited(); /** * Emitted when the job is finished, in any case. It is used to notify @@ -297,9 +308,20 @@ protected: static QUrl makeRequestUrl(QUrl baseUrl, const QString& path, const QUrlQuery& query = {}); - virtual void beforeStart(const ConnectionData* connData); - virtual void afterStart(const ConnectionData* connData, - QNetworkReply* reply); + /*! Prepares the job for execution + * + * This method is called no more than once per job lifecycle, + * when it's first scheduled for execution; in particular, it is not called + * on retries. + */ + virtual void doPrepare(); + /*! Postprocessing after the network request has been sent + * + * This method is called every time the job receives a running + * QNetworkReply object from NetworkAccessManager - basically, after + * successfully sending a network request (including retries). + */ + virtual void onSentRequest(QNetworkReply*); virtual void beforeAbandon(QNetworkReply*); /** @@ -341,8 +363,7 @@ protected: * @param reply the HTTP reply from the server * @param errorJson the JSON payload describing the error */ - virtual Status parseError(QNetworkReply* reply, - const QJsonObject& errorJson); + virtual Status parseError(QNetworkReply*, const QJsonObject& errorJson); void setStatus(Status s); void setStatus(int code, QString message); @@ -359,10 +380,12 @@ protected slots: void timeout(); private slots: - void sendRequest(bool inBackground); + void sendRequest(); void checkReply(); void gotReply(); + friend class ConnectionData; // to provide access to sendRequest() + private: void stop(); void finishJob(); diff --git a/lib/jobs/downloadfilejob.cpp b/lib/jobs/downloadfilejob.cpp index 3a03efde..4e997326 100644 --- a/lib/jobs/downloadfilejob.cpp +++ b/lib/jobs/downloadfilejob.cpp @@ -39,7 +39,7 @@ QString DownloadFileJob::targetFileName() const return (d->targetFile ? d->targetFile : d->tempFile)->fileName(); } -void DownloadFileJob::beforeStart(const ConnectionData*) +void DownloadFileJob::doPrepare() { if (d->targetFile && !d->targetFile->isReadable() && !d->targetFile->open(QIODevice::WriteOnly)) { @@ -57,7 +57,7 @@ void DownloadFileJob::beforeStart(const ConnectionData*) qCDebug(JOBS) << "Downloading to" << d->tempFile->fileName(); } -void DownloadFileJob::afterStart(const ConnectionData*, QNetworkReply* reply) +void DownloadFileJob::onSentRequest(QNetworkReply* reply) { connect(reply, &QNetworkReply::metaDataChanged, this, [this, reply] { if (!status().good()) diff --git a/lib/jobs/downloadfilejob.h b/lib/jobs/downloadfilejob.h index fa697219..b7d2d75b 100644 --- a/lib/jobs/downloadfilejob.h +++ b/lib/jobs/downloadfilejob.h @@ -19,8 +19,8 @@ private: class Private; QScopedPointer d; - void beforeStart(const ConnectionData*) override; - void afterStart(const ConnectionData*, QNetworkReply* reply) override; + void doPrepare() override; + void onSentRequest(QNetworkReply* reply) override; void beforeAbandon(QNetworkReply*) override; Status parseReply(QNetworkReply*) override; }; diff --git a/lib/room.cpp b/lib/room.cpp index 2e2f73c4..b0829252 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -1440,7 +1440,7 @@ QString Room::Private::doSendEvent(const RoomEvent* pEvent) connection->callApi(BackgroundRequest, id, pEvent->matrixType(), txnId, pEvent->contentJson())) { - Room::connect(call, &BaseJob::started, q, [this, txnId] { + Room::connect(call, &BaseJob::sentRequest, q, [this, txnId] { auto it = q->findPendingEvent(txnId); if (it == unsyncedEvents.end()) { qWarning(EVENTS) << "Pending event for transaction" << txnId -- cgit v1.2.3 From c4a20a6d9a5f6bfbd4315450ccaff8195a3f521a Mon Sep 17 00:00:00 2001 From: Kitsune Ral Date: Wed, 21 Aug 2019 08:17:09 +0900 Subject: Don't use enumerator attributes Anything after enumerators is a problem for moc before Qt 5.12; so we can't use enumerator attributes before then. --- lib/jobs/basejob.h | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'lib/jobs') diff --git a/lib/jobs/basejob.h b/lib/jobs/basejob.h index 4dc287f8..6c1b802c 100644 --- a/lib/jobs/basejob.h +++ b/lib/jobs/basejob.h @@ -66,8 +66,7 @@ public: IncorrectRequestError = IncorrectRequest, IncorrectResponse, IncorrectResponseError = IncorrectResponse, - JsonParseError - Q_DECL_ENUMERATOR_DEPRECATED_X("Use IncorrectResponse instead") + JsonParseError //< \deprecated Use IncorrectResponse instead = IncorrectResponse, TooManyRequests, TooManyRequestsError = TooManyRequests, -- cgit v1.2.3 From 97cac4b919735eb59493dd1cd528992ba90e611b Mon Sep 17 00:00:00 2001 From: Kitsune Ral Date: Tue, 27 Aug 2019 17:22:22 +0900 Subject: More compliant 'using' for chrono_literals Compilers warn on using 'using ...::operator""s' because they think we're redefining the reserved suffix. --- lib/jobs/basejob.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'lib/jobs') diff --git a/lib/jobs/basejob.cpp b/lib/jobs/basejob.cpp index 0a17431c..54931c83 100644 --- a/lib/jobs/basejob.cpp +++ b/lib/jobs/basejob.cpp @@ -32,7 +32,7 @@ using namespace Quotient; using std::chrono::seconds, std::chrono::milliseconds; -using std::chrono_literals::operator""s; +using namespace std::chrono_literals; struct NetworkReplyDeleter : public QScopedPointerDeleteLater { static inline void cleanup(QNetworkReply* reply) -- cgit v1.2.3