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-код + категория или просто
// категория + Qt errorString. Эндпоинт — без хоста.
inline QString describeNetError(QNetworkReply *reply, const QString &path) {

View File

@ -16,6 +16,8 @@
#include <QTimer>
#include <QTimeZone>
#include <QMutexLocker>
#include <QEventLoop>
#include <functional>
#include "dao/BankDAO.h"
#include "dao/GeneralLogDAO.h"
@ -34,31 +36,101 @@
using NetworkErrorUtils::sanitizePath;
using NetworkErrorUtils::describeNetError;
using NetworkErrorUtils::describeClientTimeout;
using NetworkErrorUtils::isConnectPhaseTransient;
using NetworkErrorUtils::isIdempotentTransient;
namespace {
// Прокручивает event loop пока reply не завершится либо не сработает client timeout.
// true — reply finished (success или network error), false — сработал наш QTimer.
bool awaitReply(QNetworkReply *reply, int timeoutMs) {
enum class AwaitOutcome { FinishedClean, TransportError, ClientTimeout };
// Прокручивает 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;
QTimer timer;
timer.setSingleShot(true);
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);
timer.start(timeoutMs);
loop.exec();
return timer.isActive();
if (!timer.isActive()) return AwaitOutcome::ClientTimeout;
return reply->error() == QNetworkReply::NoError
? AwaitOutcome::FinishedClean
: AwaitOutcome::TransportError;
}
// На client timeout повторяем запрос ещё 2 раза (всего до 3 попыток).
// На HTTP/network ошибках не ретраим — они детерминированы (401/409/refused/etc).
constexpr int kMaxRequestAttempts = 3;
// Ретраим при (а) нашем client-timeout и (б) ТРАНЗИЕНТНЫХ транспортных ошибках.
// Для неидемпотентных запросов (POST) — только connect-phase сбои (соединение не
// установилось ⇒ сервер запрос не видел ⇒ дубля быть не может); для идемпотентных
// (GET/PATCH/DELETE/аплоады) набор шире (см. NetworkErrorUtils.h). Детерминированные
// HTTP-ошибки (401/404/409, SSL) НЕ ретраим — их разбирает вызывающий.
constexpr int kMaxRequestAttempts = 5;
constexpr int kDefaultTimeoutMs = 30000;
constexpr int kDumpTimeoutMs = 120000;
constexpr int kDumpTimeoutMs = 300000; // архив с БД по медленному каналу
// Мониторинг — best-effort: одна попытка, короткий таймаут. Failure не должен
// блокировать потоки и накапливать QThread'ы при недоступном сервере.
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
NetworkService::NetworkService(QObject *parent) : QObject(parent) {
@ -116,28 +188,24 @@ void addFormMetadata(QHttpMultiPart *multiPart, const QByteArray &fieldValue) {
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 QNetworkRequest request(url);
QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) {
loop.quit();
});
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
const SendOutcome outcome = sendWithRetry(
[&] {
QHttpMultiPart *multiPart = buildMultiPart();
QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
return reply;
},
kDefaultTimeoutMs, path, "POST(mp)", idempotent);
QNetworkReply *reply = outcome.reply;
QJsonValue result;
if (!timeout.isActive()) {
qCritical().noquote() << "Network timeout:" << describeClientTimeout(reply, path);
reply->abort();
if (outcome.clientTimedOut) {
qCritical().noquote() << "Network timeout:" << outcome.timeoutDetail;
} else if (reply->error() == QNetworkReply::NoError) {
const QByteArray raw = reply->readAll();
QJsonParseError err;
@ -167,18 +235,12 @@ QJsonArray NetworkService::get(const QString &path, const QMap<QString, QString>
const QNetworkRequest request(url);
QNetworkReply *reply = nullptr;
bool timedOut = true;
QString lastTimeoutDetail;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
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 (GET) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->get(request); },
kDefaultTimeoutMs, path, "GET", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
const bool timedOut = outcome.clientTimedOut;
const QString lastTimeoutDetail = outcome.timeoutDetail;
QJsonArray result;
if (timedOut) {
@ -245,13 +307,13 @@ void NetworkService::getPayments() {
}
void NetworkService::postAppStatus() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId);
const bool isSuccess = post(multiPart, m_urlAppStatusUpdate).toBool();
const bool isSuccess = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId);
return multiPart;
}, m_urlAppStatusUpdate, /*idempotent=*/false).toBool();
if (!isSuccess) {
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 shortPath = sanitizePath(path);
QNetworkReply *reply = nullptr;
bool timedOut = true;
QString lastTimeoutDetail;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
reply = m_manager->post(request, bodyBytes);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (POST) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->post(request, bodyBytes); },
kDefaultTimeoutMs, path, "POST", /*idempotent=*/false);
QNetworkReply *reply = outcome.reply;
const bool timedOut = outcome.clientTimedOut;
const QString lastTimeoutDetail = outcome.timeoutDetail;
QJsonValue result;
m_lastErrorSummary.clear();
@ -515,11 +571,11 @@ void NetworkService::finishSetupWithDesktop(const QString &desktopId) {
void NetworkService::postEventStatus() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
const bool isSuccess = post(multiPart, m_urlEventStatusUpdate).toBool();
const bool isSuccess = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
return multiPart;
}, m_urlEventStatusUpdate, /*idempotent=*/false).toBool();
if (!isSuccess) {
qCritical() << "Cant post data: postEventStatus";
}
@ -528,14 +584,14 @@ void NetworkService::postEventStatus() {
}
void NetworkService::updateTransactionData() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId);
addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString());
const bool isSuccess = post(multiPart, m_urlTransactionUpdate).toBool();
const bool isSuccess = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId);
addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString());
return multiPart;
}, m_urlTransactionUpdate, /*idempotent=*/false).toBool();
if (!isSuccess) {
qCritical() << "Cant update transaction: updateTransactionData";
}
@ -544,14 +600,14 @@ void NetworkService::updateTransactionData() {
}
void NetworkService::insertTransactionData() {
QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId);
addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString());
const int id = post(multiPart, m_urlTransactionInsert).toInt();
const int id = post([this] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
addFormMetadata(multiPart, m_json);
addFormField(multiPart, "token", m_token);
addFormField(multiPart, "androidUniqueName", m_deviceId);
addFormField(multiPart, "lastNumbers", m_extraData["lastNumbers"].toString());
return multiPart;
}, m_urlTransactionInsert, /*idempotent=*/false).toInt();
if (id > 0) {
const int transactionId = m_extraData["transactionId"].toInt();
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 QString reqBodyStr = QJsonDocument(body).toJson(QJsonDocument::Compact);
QNetworkReply *reply = nullptr;
bool timedOut = true;
QString lastTimeoutDetail;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
reply = m_manager->sendCustomRequest(request, "PATCH", bodyBytes);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (PATCH) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->sendCustomRequest(request, "PATCH", bodyBytes); },
kDefaultTimeoutMs, path, "PATCH", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
const bool timedOut = outcome.clientTimedOut;
const QString lastTimeoutDetail = outcome.timeoutDetail;
QJsonValue result;
if (timedOut) {
@ -657,18 +707,12 @@ QJsonValue NetworkService::deleteJson(const QString &path, bool withAuth) {
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
}
QNetworkReply *reply = nullptr;
bool timedOut = true;
QString lastTimeoutDetail;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
reply = m_manager->deleteResource(request);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
lastTimeoutDetail = describeClientTimeout(reply, path);
qCritical().noquote() << "Network timeout (DELETE) attempt" << attempt
<< "/" << kMaxRequestAttempts << ":" << lastTimeoutDetail;
reply->abort();
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->deleteResource(request); },
kDefaultTimeoutMs, path, "DELETE", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
const bool timedOut = outcome.clientTimedOut;
const QString lastTimeoutDetail = outcome.timeoutDetail;
QJsonValue result;
if (timedOut) {
@ -704,23 +748,15 @@ QJsonValue NetworkService::getJson(const QString &path, bool withAuth) {
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
}
QNetworkReply *reply = nullptr;
bool timedOut = true;
QString lastTimeoutDetail;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
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();
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->get(request); },
kDefaultTimeoutMs, path, "getJson", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
QJsonValue result;
if (timedOut) {
if (outcome.clientTimedOut) {
qCritical().noquote() << "Network timeout (getJson) after"
<< kMaxRequestAttempts << "attempts:" << lastTimeoutDetail;
<< kMaxRequestAttempts << "attempts:" << outcome.timeoutDetail;
result = QJsonValue::Undefined;
} else if (reply->error() != QNetworkReply::NoError) {
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/");
QNetworkRequest request{QUrl(path)};
QNetworkReply *reply = nullptr;
bool timedOut = true;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
reply = m_manager->get(request);
if (awaitReply(reply, kDefaultTimeoutMs)) { timedOut = false; break; }
qWarning().noquote() << "[syncBanksIfEmpty] timeout attempt" << attempt
<< "/" << kMaxRequestAttempts;
reply->abort();
}
const SendOutcome outcome = sendWithRetry(
[&] { return m_manager->get(request); },
kDefaultTimeoutMs, path, "syncBanksIfEmpty", /*idempotent=*/true);
QNetworkReply *reply = outcome.reply;
if (timedOut) {
if (outcome.clientTimedOut) {
qWarning() << "[syncBanksIfEmpty] timeout after" << kMaxRequestAttempts << "attempts";
reply->deleteLater();
return;
@ -1416,31 +1446,25 @@ void NetworkService::postDeviceScreenshot() {
QNetworkRequest request(url);
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
QNetworkReply *reply = nullptr;
bool timedOut = true;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
const SendOutcome outcome = sendWithRetry(
[&] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
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);
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) {
if (outcome.clientTimedOut) {
qWarning() << "[NetworkService] postDeviceScreenshot timeout after"
<< kMaxRequestAttempts << "attempts for device" << device.apiId;
} 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);
multiPart->setParent(reply);
if (!awaitReply(reply, kMonitoringTimeoutMs)) {
if (awaitReply(reply, kMonitoringTimeoutMs) == AwaitOutcome::ClientTimeout) {
qWarning() << "[NetworkService] postMonitoringLog timeout";
reply->abort();
} 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();
QNetworkReply *reply = nullptr;
bool timedOut = true;
for (int attempt = 1; attempt <= kMaxRequestAttempts; ++attempt) {
if (reply) { reply->deleteLater(); reply = nullptr; }
const SendOutcome outcome = sendWithRetry(
[&] {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
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);
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);
}
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) {
bool ok = false;
QString detail;
if (outcome.clientTimedOut) {
detail = "timeout: " + outcome.timeoutDetail;
qWarning() << "[NetworkService] postMonitoringDump timeout after"
<< kMaxRequestAttempts << "attempts";
} 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();
} else {
const QByteArray raw = reply->readAll();
const QJsonDocument doc = QJsonDocument::fromJson(raw);
if (doc.isObject() && !doc.object().value("success").toBool()) {
detail = "сервер отклонил: " + QString::fromUtf8(raw).left(300);
qWarning() << "[NetworkService] postMonitoringDump rejected:" << raw;
} else {
ok = true;
qDebug() << "[NetworkService] postMonitoringDump OK:" << raw;
}
}
reply->deleteLater();
emit monitoringDumpFinished(ok, detail, archiveFilename);
}
void NetworkService::postMonitoringScreenshot(const QString &desktopId,
@ -1785,7 +1810,7 @@ void NetworkService::postMonitoringScreenshot(const QString &desktopId,
QNetworkReply *reply = m_manager->post(request, multiPart);
multiPart->setParent(reply);
if (!awaitReply(reply, kMonitoringTimeoutMs)) {
if (awaitReply(reply, kMonitoringTimeoutMs) == AwaitOutcome::ClientTimeout) {
qWarning() << "[NetworkService] postMonitoringScreenshot timeout";
reply->abort();
} else if (reply->error() != QNetworkReply::NoError) {

View File

@ -7,6 +7,7 @@
#include <QMutex>
#include <QSharedPointer>
#include <atomic>
#include <functional>
class QWidget;
@ -145,6 +146,10 @@ signals:
// healthy=false при kSyncFailureThreshold подряд неудачных тиков; healthy=true
// при первом успехе после такой серии. consecutiveFailures даёт текущий счёт.
void syncHealthChanged(bool healthy, int consecutiveFailures);
// Результат postMonitoringDump (включая ручную отправку логов из меню).
// archiveFilename — ключ, по которому инициатор отличает свой аплоад (синглтон
// общий). ok=false ⇒ detail содержит причину (таймаут/сетевая/HTTP-ошибка).
void monitoringDumpFinished(bool ok, QString detail, QString archiveFilename);
private:
QByteArray m_json;
@ -207,7 +212,10 @@ private:
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);
QJsonValue getJson(const QString &path, bool withAuth = false);
QJsonValue postJson(const QString &path, const QJsonObject &body, bool withAuth = false);

View File

@ -14,6 +14,7 @@
#include <QProcess>
#include <QPixmap>
#include <QVBoxLayout>
#include <memory>
#include "dump/TaskDumpRecorder.h"
#include "dao/GeneralLogDAO.h"
@ -284,6 +285,32 @@ void FileLogWindow::sendLogsToBackend() {
qDebug() << "[sendLogsToBackend] dispatching to NetworkService::postMonitoringDump"
<< "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",
Qt::QueuedConnection,
Q_ARG(QString, text),
@ -291,15 +318,12 @@ void FileLogWindow::sendLogsToBackend() {
Q_ARG(QByteArray, QByteArray()),
Q_ARG(QString, archiveFilename));
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
if (!dispatched) {
*handled = true;
QObject::disconnect(*conn);
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
QMessageBox::warning(this, "Ошибка",
"Не удалось поставить отправку в очередь NetworkService.");
return;
}
QMessageBox::information(this, "Готово",
"Логи поставлены в очередь на отправку на бэкенд.");
}