1
0
forked from BRT/arc

Refactor network requests: consolidate retry logic with backoff, handle transient errors, and improve timeout handling across NetworkService methods. Emit result signals for monitoring dumps.

This commit is contained in:
slava 2026-06-05 23:59:42 +05:00
parent cef3515790
commit fac49447bf
4 changed files with 293 additions and 200 deletions

View File

@ -56,6 +56,42 @@ inline QString networkErrorCategory(QNetworkReply::NetworkError err) {
} }
} }
// Транзиентная ошибка CONNECT-фазы: сбой произошёл ДО того, как байты запроса
// ушли на сервер (соединение не установилось), поэтому повтор безопасен даже для
// неидемпотентных POST — дубля быть не может. Сюда же ETIMEDOUT-connect, который
// Qt отдаёт как UnknownNetworkError "Connection timed out".
inline bool isConnectPhaseTransient(QNetworkReply::NetworkError err) {
switch (err) {
case QNetworkReply::ConnectionRefusedError:
case QNetworkReply::HostNotFoundError:
case QNetworkReply::ProxyConnectionRefusedError:
case QNetworkReply::ProxyNotFoundError:
case QNetworkReply::ProxyTimeoutError:
case QNetworkReply::UnknownNetworkError:
case QNetworkReply::TemporaryNetworkFailureError:
case QNetworkReply::NetworkSessionFailedError:
return true;
default:
return false;
}
}
// Более широкий набор транзиентных ошибок — безопасен ТОЛЬКО для идемпотентных
// запросов (GET/PATCH/DELETE, аплоады), т.к. сбой мог случиться уже ПОСЛЕ отправки
// тела (сервер мог его обработать) → повтор неидемпотентного POST дал бы дубль.
inline bool isIdempotentTransient(QNetworkReply::NetworkError err) {
if (isConnectPhaseTransient(err)) return true;
switch (err) {
case QNetworkReply::RemoteHostClosedError:
case QNetworkReply::TimeoutError:
case QNetworkReply::ProxyConnectionClosedError:
case QNetworkReply::ServiceUnavailableError:
return true;
default:
return false;
}
}
// Развёрнутое описание сетевой ошибки: HTTP-код + категория или просто // Развёрнутое описание сетевой ошибки: HTTP-код + категория или просто
// категория + Qt errorString. Эндпоинт — без хоста. // категория + Qt errorString. Эндпоинт — без хоста.
inline QString describeNetError(QNetworkReply *reply, const QString &path) { inline QString describeNetError(QNetworkReply *reply, const QString &path) {

View File

@ -16,6 +16,8 @@
#include <QTimer> #include <QTimer>
#include <QTimeZone> #include <QTimeZone>
#include <QMutexLocker> #include <QMutexLocker>
#include <QEventLoop>
#include <functional>
#include "dao/BankDAO.h" #include "dao/BankDAO.h"
#include "dao/GeneralLogDAO.h" #include "dao/GeneralLogDAO.h"
@ -34,31 +36,101 @@
using NetworkErrorUtils::sanitizePath; using NetworkErrorUtils::sanitizePath;
using NetworkErrorUtils::describeNetError; using NetworkErrorUtils::describeNetError;
using NetworkErrorUtils::describeClientTimeout; using NetworkErrorUtils::describeClientTimeout;
using NetworkErrorUtils::isConnectPhaseTransient;
using NetworkErrorUtils::isIdempotentTransient;
namespace { namespace {
// Прокручивает event loop пока reply не завершится либо не сработает client timeout. enum class AwaitOutcome { FinishedClean, TransportError, ClientTimeout };
// true — reply finished (success или network error), false — сработал наш QTimer.
bool awaitReply(QNetworkReply *reply, int timeoutMs) { // Прокручивает event loop пока reply не завершится (finished приходит и на успех,
// и сразу после errorOccurred) либо не сработает наш QTimer. Раньше здесь отдельно
// коннектился errorOccurred → loop.quit(), из-за чего транспортная ошибка считалась
// «успешным завершением» и цикл ретраев делал break на первой же Connection timed out.
// FinishedClean — reply завершился без ошибки;
// TransportError — reply завершился, но reply->error() != NoError;
// ClientTimeout — наш таймер сработал раньше, reply ещё в полёте (нужен abort()).
AwaitOutcome awaitReply(QNetworkReply *reply, int timeoutMs) {
QEventLoop loop; QEventLoop loop;
QTimer timer; QTimer timer;
timer.setSingleShot(true); timer.setSingleShot(true);
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
QObject::connect(reply, &QNetworkReply::errorOccurred, &loop,
[&loop](QNetworkReply::NetworkError) { loop.quit(); });
QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); QObject::connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit);
timer.start(timeoutMs); timer.start(timeoutMs);
loop.exec(); loop.exec();
return timer.isActive(); if (!timer.isActive()) return AwaitOutcome::ClientTimeout;
return reply->error() == QNetworkReply::NoError
? AwaitOutcome::FinishedClean
: AwaitOutcome::TransportError;
} }
// На client timeout повторяем запрос ещё 2 раза (всего до 3 попыток). // Ретраим при (а) нашем client-timeout и (б) ТРАНЗИЕНТНЫХ транспортных ошибках.
// На HTTP/network ошибках не ретраим — они детерминированы (401/409/refused/etc). // Для неидемпотентных запросов (POST) — только connect-phase сбои (соединение не
constexpr int kMaxRequestAttempts = 3; // установилось ⇒ сервер запрос не видел ⇒ дубля быть не может); для идемпотентных
// (GET/PATCH/DELETE/аплоады) набор шире (см. NetworkErrorUtils.h). Детерминированные
// HTTP-ошибки (401/404/409, SSL) НЕ ретраим — их разбирает вызывающий.
constexpr int kMaxRequestAttempts = 5;
constexpr int kDefaultTimeoutMs = 30000; constexpr int kDefaultTimeoutMs = 30000;
constexpr int kDumpTimeoutMs = 120000; constexpr int kDumpTimeoutMs = 300000; // архив с БД по медленному каналу
// Мониторинг — best-effort: одна попытка, короткий таймаут. Failure не должен // Мониторинг — best-effort: одна попытка, короткий таймаут. Failure не должен
// блокировать потоки и накапливать QThread'ы при недоступном сервере. // блокировать потоки и накапливать QThread'ы при недоступном сервере.
constexpr int kMonitoringTimeoutMs = 10000; constexpr int kMonitoringTimeoutMs = 10000;
constexpr int kBackoffBaseMs = 1000;
constexpr int kBackoffMaxMs = 4000;
// Пауза между попытками: 1с, 2с, 4с (cap). Через QEventLoop, а не QThread::sleep —
// event loop потока остаётся живым (abort/deleteLater/queued-слоты продолжают идти).
void backoffSleep(int attempt) {
const int ms = qMin(kBackoffBaseMs << (attempt - 1), kBackoffMaxMs);
QEventLoop loop;
QTimer::singleShot(ms, &loop, &QEventLoop::quit);
loop.exec();
}
struct SendOutcome {
QNetworkReply *reply = nullptr; // последний reply; вызывающий парсит и делает deleteLater
bool clientTimedOut = false; // финальная попытка упёрлась в наш QTimer (reply aborted)
QString timeoutDetail; // describeClientTimeout последнего таймаута
};
// Выполняет issueRequest() с таймаутом и ретраями транзиентных сбоев + бэкоффом.
// issueRequest должен КАЖДЫЙ раз создавать новый reply (и, для multipart, новый
// QHttpMultiPart — он потребляется за попытку). Возвращает последний reply, чтобы
// вызывающий сохранил свой разбор ответа/ошибки.
SendOutcome sendWithRetry(const std::function<QNetworkReply*()> &issueRequest,
int timeoutMs, const QString &path,
const char *method, bool idempotent) {
QNetworkReply *reply = nullptr;
QString timeoutDetail;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
reply = issueRequest();
switch (awaitReply(reply, timeoutMs)) {
case AwaitOutcome::FinishedClean:
return { reply, false, {} };
case AwaitOutcome::TransportError: {
const QNetworkReply::NetworkError err = reply->error();
const bool transient = isConnectPhaseTransient(err)
|| (idempotent && isIdempotentTransient(err));
if (transient && attempt < kMaxRequestAttempts) {
qCritical().noquote() << "Network transient error (" << method << ") attempt"
<< attempt << "/" << kMaxRequestAttempts << ":"
<< describeNetError(reply, path) << "— retrying after backoff";
backoffSleep(attempt);
continue;
}
return { reply, false, {} }; // детерминированная ошибка или попытки исчерпаны
}
case AwaitOutcome::ClientTimeout:
timeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (" << method << ") attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << timeoutDetail;
reply->abort();
if (attempt < kMaxRequestAttempts) { backoffSleep(attempt); continue; }
return { reply, true, timeoutDetail };
}
}
return { reply, true, timeoutDetail }; // недостижимо
}
} // namespace } // namespace
NetworkService::NetworkService(QObject *parent) : QObject(parent) { NetworkService::NetworkService(QObject *parent) : QObject(parent) {
@ -116,28 +188,24 @@ void addFormMetadata(QHttpMultiPart *multiPart, const QByteArray &fieldValue) {
multiPart->append(jsonPart); multiPart->append(jsonPart);
} }
QJsonValue NetworkService::post(QHttpMultiPart *multiPart, const QString &path) { QJsonValue NetworkService::post(const std::function<QHttpMultiPart*()> &buildMultiPart,
const QString &path, bool idempotent) {
const QUrl url(path); const QUrl url(path);
const QNetworkRequest request(url); const QNetworkRequest request(url);
QNetworkReply *reply = m_manager->post(request, multiPart); const SendOutcome outcome = sendWithRetry(
multiPart->setParent(reply); [&] {
QHttpMultiPart *multiPart = buildMultiPart();
QEventLoop loop; QNetworkReply *reply = m_manager->post(request, multiPart);
QTimer timeout; multiPart->setParent(reply);
timeout.setSingleShot(true); return reply;
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); },
connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { kDefaultTimeoutMs, path, "POST(mp)", idempotent);
loop.quit(); QNetworkReply *reply = outcome.reply;
});
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
QJsonValue result; QJsonValue result;
if (!timeout.isActive()) { if (outcome.clientTimedOut) {
qCritical().noquote() << "Network timeout:" << describeClientTimeout(reply, path); qCritical().noquote() << "Network timeout:" << outcome.timeoutDetail;
reply->abort();
} else if (reply->error() == QNetworkReply::NoError) { } else if (reply->error() == QNetworkReply::NoError) {
const QByteArray raw = reply->readAll(); const QByteArray raw = reply->readAll();
QJsonParseError err; QJsonParseError err;
@ -167,18 +235,12 @@ QJsonArray NetworkService::get(const QString &path, const QMap<QString, QString>
const QNetworkRequest request(url); const QNetworkRequest request(url);
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] { return m_manager->get(request); },
QString lastTimeoutDetail; kDefaultTimeoutMs, path, "GET", /*idempotent=*/true);
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { QNetworkReply *reply = outcome.reply;
if (reply) { reply->deleteLater(); reply = nullptr; } const bool timedOut = outcome.clientTimedOut;
reply = m_manager->get(request); const QString lastTimeoutDetail = outcome.timeoutDetail;
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (GET) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
QJsonArray result; QJsonArray result;
if (timedOut) { if (timedOut) {
@ -245,13 +307,13 @@ void NetworkService::getPayments() {
} }
void NetworkService::postAppStatus() { void NetworkService::postAppStatus() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); const bool isSuccess = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json); addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token); addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId); addFormField(multiPart, "androidUniqueName", m_deviceId);
return multiPart;
const bool isSuccess = post(multiPart, m_urlAppStatusUpdate).toBool(); }, m_urlAppStatusUpdate, /*idempotent=*/false).toBool();
if (!isSuccess) { if (!isSuccess) {
qCritical() << "Cant post data: postAppStatus"; qCritical() << "Cant post data: postAppStatus";
} }
@ -271,18 +333,12 @@ QJsonValue NetworkService::postJson(const QString &path, const QJsonObject &body
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
const QString shortPath = sanitizePath(path); const QString shortPath = sanitizePath(path);
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] { return m_manager->post(request, bodyBytes); },
QString lastTimeoutDetail; kDefaultTimeoutMs, path, "POST", /*idempotent=*/false);
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { QNetworkReply *reply = outcome.reply;
if (reply) { reply->deleteLater(); reply = nullptr; } const bool timedOut = outcome.clientTimedOut;
reply = m_manager->post(request, bodyBytes); const QString lastTimeoutDetail = outcome.timeoutDetail;
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (POST) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
QJsonValue result; QJsonValue result;
m_lastErrorSummary.clear(); m_lastErrorSummary.clear();
@ -515,11 +571,11 @@ void NetworkService::finishSetupWithDesktop(const QString &desktopId) {
void NetworkService::postEventStatus() { void NetworkService::postEventStatus() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); const bool isSuccess = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json); addFormMetadata(multiPart, m_json);
return multiPart;
const bool isSuccess = post(multiPart, m_urlEventStatusUpdate).toBool(); }, m_urlEventStatusUpdate, /*idempotent=*/false).toBool();
if (!isSuccess) { if (!isSuccess) {
qCritical() << "Cant post data: postEventStatus"; qCritical() << "Cant post data: postEventStatus";
} }
@ -528,14 +584,14 @@ void NetworkService::postEventStatus() {
} }
void NetworkService::updateTransactionData() { void NetworkService::updateTransactionData() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); const bool isSuccess = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json); addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token); addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId); addFormField(multiPart, "androidUniqueName", m_deviceId);
addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString()); addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString());
return multiPart;
const bool isSuccess = post(multiPart, m_urlTransactionUpdate).toBool(); }, m_urlTransactionUpdate, /*idempotent=*/false).toBool();
if (!isSuccess) { if (!isSuccess) {
qCritical() << "Cant update transaction: updateTransactionData"; qCritical() << "Cant update transaction: updateTransactionData";
} }
@ -544,14 +600,14 @@ void NetworkService::updateTransactionData() {
} }
void NetworkService::insertTransactionData() { void NetworkService::insertTransactionData() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); const int id = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json); addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token); addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId); addFormField(multiPart, "androidUniqueName", m_deviceId);
addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString()); addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString());
return multiPart;
const int id = post(multiPart, m_urlTransactionInsert).toInt(); }, m_urlTransactionInsert, /*idempotent=*/false).toInt();
if (id > 0) { if (id > 0) {
const int transactionId = m_extraData["transactionId"].toInt(); const int transactionId = m_extraData["transactionId"].toInt();
if (!TransactionDAO::updateExternalTransactionId(transactionId, id)) { if (!TransactionDAO::updateExternalTransactionId(transactionId, id)) {
@ -572,18 +628,12 @@ QJsonValue NetworkService::patchJson(const QString &path, const QJsonObject &bod
const QByteArray bodyBytes = QJsonDocument(body).toJson(); const QByteArray bodyBytes = QJsonDocument(body).toJson();
const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact); const QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] { return m_manager->sendCustomRequest(request, "PATCH", bodyBytes); },
QString lastTimeoutDetail; kDefaultTimeoutMs, path, "PATCH", /*idempotent=*/true);
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { QNetworkReply *reply = outcome.reply;
if (reply) { reply->deleteLater(); reply = nullptr; } const bool timedOut = outcome.clientTimedOut;
reply = m_manager->sendCustomRequest(request, "PATCH", bodyBytes); const QString lastTimeoutDetail = outcome.timeoutDetail;
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (PATCH) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
QJsonValue result; QJsonValue result;
if (timedOut) { if (timedOut) {
@ -657,18 +707,12 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) {
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
} }
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] { return m_manager->deleteResource(request); },
QString lastTimeoutDetail; kDefaultTimeoutMs, path, "DELETE", /*idempotent=*/true);
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { QNetworkReply *reply = outcome.reply;
if (reply) { reply->deleteLater(); reply = nullptr; } const bool timedOut = outcome.clientTimedOut;
reply = m_manager->deleteResource(request); const QString lastTimeoutDetail = outcome.timeoutDetail;
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (DELETE) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
QJsonValue result; QJsonValue result;
if (timedOut) { if (timedOut) {
@ -704,23 +748,15 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) {
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
} }
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] { return m_manager->get(request); },
QString lastTimeoutDetail; kDefaultTimeoutMs, path, "getJson", /*idempotent=*/true);
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { QNetworkReply *reply = outcome.reply;
if (reply) { reply->deleteLater(); reply = nullptr; }
reply = m_manager->get(request);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (getJson) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
QJsonValue result; QJsonValue result;
if (timedOut) { if (outcome.clientTimedOut) {
qCritical().noquote() << "Network timeout (getJson) after" qCritical().noquote() << "Network timeout (getJson) after"
<< kMaxRequestAttempts << "attempts:" << lastTimeoutDetail; << kMaxRequestAttempts << "attempts:" << outcome.timeoutDetail;
result = QJsonValue::Undefined; result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "Network error (GET):" << describeNetError(reply, path) qCritical().noquote() << "Network error (GET):" << describeNetError(reply, path)
@ -900,18 +936,12 @@ void NetworkService::syncBanksIfEmpty() {
const QString path = QStringLiteral("https://api.brtservice.io/api/rest/info/"); const QString path = QStringLiteral("https://api.brtservice.io/api/rest/info/");
QNetworkRequest request{QUrl(path)}; QNetworkRequest request{QUrl(path)};
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] { return m_manager->get(request); },
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { kDefaultTimeoutMs, path, "syncBanksIfEmpty", /*idempotent=*/true);
if (reply) { reply->deleteLater(); reply = nullptr; } QNetworkReply *reply = outcome.reply;
reply = m_manager->get(request);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
qWarning().noquote() << "[syncBanksIfEmpty] timeout attempt" << attempt
<< "/" << kMaxRequestAttempts;
reply->abort();
}
if (timedOut) { if (outcome.clientTimedOut) {
qWarning() << "[syncBanksIfEmpty] timeout after" << kMaxRequestAttempts << "attempts"; qWarning() << "[syncBanksIfEmpty] timeout after" << kMaxRequestAttempts << "attempts";
reply->deleteLater(); reply->deleteLater();
return; return;
@ -1416,31 +1446,25 @@ void NetworkService::postDeviceScreenshot() {
QNetworkRequest request(url); QNetworkRequest request(url);
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] {
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
if (reply) { reply->deleteLater(); reply = nullptr; } QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg");
filePart.setHeader(
QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"file\"; filename=\"%1.jpg\"").arg(m_deviceId)
);
filePart.setBody(m_json);
multiPart->append(filePart);
QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
return reply;
},
kDefaultTimeoutMs, url.toString(), "postDeviceScreenshot", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); if (outcome.clientTimedOut) {
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg");
filePart.setHeader(
QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"file\"; filename=\"%1.jpg\"").arg(m_deviceId)
);
filePart.setBody(m_json);
multiPart->append(filePart);
reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
qWarning() << "[NetworkService] postDeviceScreenshot timeout attempt"
<< attempt << "/" << kMaxRequestAttempts << "for device" << device.apiId;
reply->abort();
}
if (timedOut) {
qWarning() << "[NetworkService] postDeviceScreenshot timeout after" qWarning() << "[NetworkService] postDeviceScreenshot timeout after"
<< kMaxRequestAttempts << "attempts for device" << device.apiId; << kMaxRequestAttempts << "attempts for device" << device.apiId;
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
@ -1679,7 +1703,7 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
QNetworkReply *reply = m_manager->post(request, multiPart); QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply); multiPart->setParent(reply);
if (!awaitReply(reply, kMonitoringTimeoutMs)) { if (awaitReply(reply, kMonitoringTimeoutMs) == AwaitOutcome::ClientTimeout) {
qWarning() << "[NetworkService] postMonitoringLog timeout"; qWarning() << "[NetworkService] postMonitoringLog timeout";
reply->abort(); reply->abort();
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
@ -1703,59 +1727,60 @@ void NetworkService::postMonitoringDump(const QString &text, const QByteArray &a
qDebug() << "[NetworkService] postMonitoringDump POST" << url.toString(); qDebug() << "[NetworkService] postMonitoringDump POST" << url.toString();
QNetworkReply *reply = nullptr; const SendOutcome outcome = sendWithRetry(
bool timedOut = true; [&] {
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) { auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
if (reply) { reply->deleteLater(); reply = nullptr; } addFormField(multiPart, "text", text);
if (!archive.isEmpty()) {
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip");
filePart.setHeader(
QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"file\"; filename=\"%1\"").arg(archiveFilename)
);
filePart.setBody(archive);
multiPart->append(filePart);
}
if (!image.isEmpty()) {
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");
imagePart.setHeader(
QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"image\"; filename=\"final_screen.png\"")
);
imagePart.setBody(image);
multiPart->append(imagePart);
}
QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
return reply;
},
kDumpTimeoutMs, url.toString(), "postMonitoringDump", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); bool ok = false;
addFormField(multiPart, "text", text); QString detail;
if (!archive.isEmpty()) { if (outcome.clientTimedOut) {
QHttpPart filePart; detail = "timeout: " + outcome.timeoutDetail;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip");
filePart.setHeader(
QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"file\"; filename=\"%1\"").arg(archiveFilename)
);
filePart.setBody(archive);
multiPart->append(filePart);
}
if (!image.isEmpty()) {
QHttpPart imagePart;
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");
imagePart.setHeader(
QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"image\"; filename=\"final_screen.png\"")
);
imagePart.setBody(image);
multiPart->append(imagePart);
}
reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
if (awaitReply(reply, kDumpTimeoutMs)) { timedOut = false; break; }
qWarning() << "[NetworkService] postMonitoringDump timeout attempt"
<< attempt << "/" << kMaxRequestAttempts;
reply->abort();
}
if (timedOut) {
qWarning() << "[NetworkService] postMonitoringDump timeout after" qWarning() << "[NetworkService] postMonitoringDump timeout after"
<< kMaxRequestAttempts << "attempts"; << kMaxRequestAttempts << "attempts";
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[NetworkService] postMonitoringDump failed:" << reply->errorString() detail = describeNetError(reply, url.toString());
qWarning() << "[NetworkService] postMonitoringDump failed:" << detail
<< "\n response body:" << reply->readAll(); << "\n response body:" << reply->readAll();
} else { } else {
const QByteArray raw = reply->readAll(); const QByteArray raw = reply->readAll();
const QJsonDocument doc = QJsonDocument::fromJson(raw); const QJsonDocument doc = QJsonDocument::fromJson(raw);
if (doc.isObject() && !doc.object().value("success").toBool()) { if (doc.isObject() && !doc.object().value("success").toBool()) {
detail = "сервер отклонил: " + QString::fromUtf8(raw).left(300);
qWarning() << "[NetworkService] postMonitoringDump rejected:" << raw; qWarning() << "[NetworkService] postMonitoringDump rejected:" << raw;
} else { } else {
ok = true;
qDebug() << "[NetworkService] postMonitoringDump OK:" << raw; qDebug() << "[NetworkService] postMonitoringDump OK:" << raw;
} }
} }
reply->deleteLater(); reply->deleteLater();
emit monitoringDumpFinished(ok, detail, archiveFilename);
} }
void NetworkService::postMonitoringScreenshot(const QString &desktopId, void NetworkService::postMonitoringScreenshot(const QString &desktopId,
@ -1785,7 +1810,7 @@ void NetworkService::postMonitoringScreenshot(const QString &desktopId,
QNetworkReply *reply = m_manager->post(request, multiPart); QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply); multiPart->setParent(reply);
if (!awaitReply(reply, kMonitoringTimeoutMs)) { if (awaitReply(reply, kMonitoringTimeoutMs) == AwaitOutcome::ClientTimeout) {
qWarning() << "[NetworkService] postMonitoringScreenshot timeout"; qWarning() << "[NetworkService] postMonitoringScreenshot timeout";
reply->abort(); reply->abort();
} else if (reply->error() != QNetworkReply::NoError) { } else if (reply->error() != QNetworkReply::NoError) {

View File

@ -7,6 +7,7 @@
#include <QMutex> #include <QMutex>
#include <QSharedPointer> #include <QSharedPointer>
#include <atomic> #include <atomic>
#include <functional>
class QWidget; class QWidget;
@ -145,6 +146,10 @@ signals:
// healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true // healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true
// при первом успехе после такой серии. consecutiveFailures даёт текущий счёт. // при первом успехе после такой серии. consecutiveFailures даёт текущий счёт.
void syncHealthChanged(bool healthy, int consecutiveFailures); void syncHealthChanged(bool healthy, int consecutiveFailures);
// Результат postMonitoringDump (включая ручную отправку логов из меню).
// archiveFilename — ключ, по которому инициатор отличает свой аплоад (синглтон
// общий). ok=false ⇒ detail содержит причину (таймаут/сетевая/HTTP-ошибка).
void monitoringDumpFinished(bool ok, QString detail, QString archiveFilename);
private: private:
QByteArray m_json; QByteArray m_json;
@ -207,7 +212,10 @@ private:
QNetworkAccessManager *m_manager; QNetworkAccessManager *m_manager;
QJsonValue post(QHttpMultiPart *multiPart, const QString &path); // buildMultiPart вызывается на КАЖДУЮ попытку (multipart потребляется за запрос).
// idempotent=false ⇒ ретраим только connect-phase сбои (без риска двойной отправки).
QJsonValue post(const std::function<QHttpMultiPart*()> &buildMultiPart,
const QString &path, bool idempotent);
QJsonArray get(const QString &path, const QMap<QString, QString> &params); QJsonArray get(const QString &path, const QMap<QString, QString> &params);
QJsonValue getJson(const QString &path, bool withAuth = false); QJsonValue getJson(const QString &path, bool withAuth = false);
QJsonValue postJson(const QString &path, const QJsonObject &body, bool withAuth = false); QJsonValue postJson(const QString &path, const QJsonObject &body, bool withAuth = false);

View File

@ -14,6 +14,7 @@
#include <QProcess> #include <QProcess>
#include <QPixmap> #include <QPixmap>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <memory>
#include "dump/TaskDumpRecorder.h" #include "dump/TaskDumpRecorder.h"
#include "dao/GeneralLogDAO.h" #include "dao/GeneralLogDAO.h"
@ -284,6 +285,32 @@ void FileLogWindow::sendLogsToBackend() {
qDebug() << "[sendLogsToBackend] dispatching to NetworkService::postMonitoringDump" qDebug() << "[sendLogsToBackend] dispatching to NetworkService::postMonitoringDump"
<< "archive.size=" << archiveBytes.size() << "filename=" << archiveFilename; << "archive.size=" << archiveBytes.size() << "filename=" << archiveFilename;
// Подписываемся на результат ДО диспатча. svc живёт в потоке paymentThread —
// соединение автоматически становится QueuedConnection, лямбда исполнится в
// UI-потоке. Контекст = this ⇒ при закрытии окна Qt сам отключит слот (без
// обращения к висячему указателю). Фильтруем по своему archiveFilename:
// синглтон общий, параллельно могут уходить и другие дампы (TaskDumpRecorder).
auto handled = std::make_shared<bool>(false);
auto conn = std::make_shared<QMetaObject::Connection>();
*conn = connect(svc, &NetworkService::monitoringDumpFinished, this,
[this, conn, handled, expected = archiveFilename](
bool ok, const QString &detail, const QString &fn) {
if (fn != expected || *handled) return;
*handled = true;
QObject::disconnect(*conn);
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
if (ok) {
QMessageBox::information(this, "Готово", "Логи отправлены на бэкенд.");
} else {
QMessageBox::warning(this, "Ошибка",
"Не удалось отправить логи: " + detail);
}
});
m_sendBtn->setText("Отправка...");
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump", const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
Qt::QueuedConnection, Qt::QueuedConnection,
Q_ARG(QString, text), Q_ARG(QString, text),
@ -291,15 +318,12 @@ void FileLogWindow::sendLogsToBackend() {
Q_ARG(QByteArray, QByteArray()), Q_ARG(QByteArray, QByteArray()),
Q_ARG(QString, archiveFilename)); Q_ARG(QString, archiveFilename));
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
if (!dispatched) { if (!dispatched) {
*handled = true;
QObject::disconnect(*conn);
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
QMessageBox::warning(this, "Ошибка", QMessageBox::warning(this, "Ошибка",
"Не удалось поставить отправку в очередь NetworkService."); "Не удалось поставить отправку в очередь NetworkService.");
return;
} }
QMessageBox::information(this, "Готово",
"Логи поставлены в очередь на отправку на бэкенд.");
} }