Files
portal/app/tests/Unit/Sms/MtsSmsProviderConnectionTest.php
T
Дмитрий fb40aaf7c0 fix,смс: канал МТС переиспользует соединение и замолкает при обрывах
Защита оператора считает не запросы, а попытки ОТКРЫТЬ соединение. Прежний код
собирал HTTP-клиент заново на каждое сообщение: рассылка на тысячу номеров
открывала тысячу соединений подряд и выглядела для их защиты как атака, после
чего адрес уходил в чёрный список на часы. Замеры 05-07.08.2026, заявка
RUMAAS-73856: из шести одинаковых запросов проходили один-два, остальные
молча дропались, пауза пять минут доступ не возвращала.

Что сделано:

- общий обработчик curl на объект — шесть запросов идут по ОДНОМУ соединению
  вместо шести. Замерено вживую на безобидном узле: начиная со второго запроса
  установка соединения и согласование шифрования равны нулю, время запроса
  втрое меньше — 0,13 с против 0,41 с;

- предохранитель SmsConnectionBreaker: пять обрывов подряд — и канал молчит
  десять минут, в сеть не выходя вовсе. Это лечит главное: раньше каждая
  неудачная попытка сыпала семь безответных стуков и сама продлевала
  блокировку. Считаются ТОЛЬКО обрывы связи; прикладной отказ вроде
  отсутствия имени отправителя предохранитель не трогает — сеть-то жива;

- ожидание соединения 3 с вместо прежних десяти, чтобы не копить оборванные
  попытки, и своё имя клиента LiderraSms/1.0 вместо безымянного робота.

Подменяется обработчик, а НЕ клиент целиком: подмена клиента отключила бы
подделку сети в тестах, и они пошли бы в настоящий интернет. Записано
комментарием в коде.

Поведение отправки не меняется. Мост SMS_MTS_CONNECT_TO не тронут, заявка
RUMAAS-73856 остаётся открытой.

Проверено: 500 тестов канала зелёные, статанализ ноль замечаний, оформление
чистое. Новый сторож перед починкой был красным.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:00:17 +03:00

185 lines
6.2 KiB
PHP

<?php
declare(strict_types=1);
use App\Services\Sms\Providers\MtsSmsProvider;
use App\Services\Sms\SmsOutgoing;
use App\Services\Sms\SmsSendException;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Как канал МТС ходит в сеть.
*
* 🔴 Зачем: защита МТС (Qrator) считает НОВЫЕ TCP-соединения, а не запросы.
* Прежний код открывал новое соединение на каждое сообщение — рассылка на тысячу
* номеров выглядела для их защиты как атака, и адрес уходил в чёрный список
* на часы (замеры 05-07.08.2026, заявка RUMAAS-73856). Здесь заперты три вещи:
* переиспользование соединения, короткие таймауты и предохранитель, который
* прекращает стучаться после серии отказов.
*/
function mts_conn_provider(): MtsSmsProvider
{
return new MtsSmsProvider(
token: 'token-xxx',
serves: ['mts'],
priceKopecks: ['*' => 400],
baseUrl: 'https://api.mts.ru/client-omni-adapter_production/1.0.2',
naming: 'MTSM_Test',
connectTo: null,
);
}
function mts_conn_message(): SmsOutgoing
{
return new SmsOutgoing(
phone: '79990000001',
body: 'Здравствуйте!',
senderName: 'liderra.ru',
operator: 'МТС',
segments: 1,
);
}
function mts_conn_ok(): array
{
return [
'code' => 0,
'description' => '',
'data' => ['submitResults' => [['msid' => '79990000001', 'messageID' => 1, 'code' => 'OK']]],
];
}
beforeEach(function () {
Cache::flush();
});
it('переиспользует одно соединение вместо нового на каждое сообщение', function () {
$provider = mts_conn_provider();
// Соединения живут внутри обработчика curl: один и тот же обработчик —
// одно и то же соединение на много запросов.
expect($provider->httpHandler())->toBe($provider->httpHandler());
});
it('представляется своим именем — чтобы защита видела известного клиента', function () {
Http::fake(['api.mts.ru/*' => Http::response(mts_conn_ok(), 200)]);
mts_conn_provider()->send(mts_conn_message());
Http::assertSent(fn ($request) => str_contains(
(string) ($request->header('User-Agent')[0] ?? ''),
'Liderra',
));
});
it('ждёт соединения недолго — чтобы не держать оборванные попытки', function () {
$opts = mts_conn_provider()->httpOptions();
expect($opts['connect_timeout'])->toBeLessThanOrEqual(5)
->and($opts['timeout'])->toBeLessThanOrEqual(30);
});
it('после серии отказов перестаёт стучаться вовсе', function () {
// 🪤 Подделка сети, которая БРОСАЕТ обрыв, запрос в журнал Laravel не пишет —
// assertSentCount на ней всегда ноль. Поэтому считаем заходы сами.
$zahodov = 0;
Http::fake(function () use (&$zahodov) {
$zahodov++;
throw new ConnectionException('cURL error 28: Timeout was reached');
});
$provider = mts_conn_provider();
// Пять отказов подряд — предохранитель размыкается.
for ($i = 1; $i <= 5; $i++) {
try {
$provider->send(mts_conn_message());
} catch (SmsSendException) {
// ожидаемо
}
}
expect($zahodov)->toBe(5);
// Шестая попытка НЕ должна уйти в сеть вовсе.
$exception = null;
try {
$provider->send(mts_conn_message());
} catch (SmsSendException $e) {
$exception = $e;
}
expect($exception)->not->toBeNull()
->and($exception->terminal)->toBeFalse()
->and($zahodov)->toBe(5);
});
it('удачный заход сбрасывает счётчик отказов', function () {
$zahodov = 0;
// 🪤 Режим держим в объекте, а не в простой переменной: статанализатор не видит
// присваивания по ссылке из замыкания и считает сравнение всегда истинным.
$set = new class
{
public string $rezhim = 'obryv';
};
Http::fake(function () use (&$zahodov, $set) {
$zahodov++;
if ($set->rezhim === 'obryv') {
throw new ConnectionException('cURL error 28: Timeout was reached');
}
return Http::response(mts_conn_ok(), 200);
});
$provider = mts_conn_provider();
for ($i = 1; $i <= 4; $i++) {
try {
$provider->send(mts_conn_message());
} catch (SmsSendException) {
// ожидаемо
}
}
$set->rezhim = 'otvet';
$provider->send(mts_conn_message());
// Счётчик обнулён: следующие четыре отказа предохранитель ещё не размыкают.
$set->rezhim = 'obryv';
for ($i = 1; $i <= 4; $i++) {
try {
$provider->send(mts_conn_message());
} catch (SmsSendException) {
// ожидаемо
}
}
expect($zahodov)->toBe(9);
});
it('отказ по существу дела предохранитель не трогает — сеть-то жива', function () {
Http::fake(['api.mts.ru/*' => Http::response(['code' => 3, 'description' => 'нет имени'], 200)]);
$provider = mts_conn_provider();
for ($i = 1; $i <= 6; $i++) {
try {
$provider->send(mts_conn_message());
} catch (SmsSendException) {
// ожидаемо: прикладной отказ
}
}
// Все шесть ушли в сеть — предохранитель не сработал.
Http::assertSentCount(6);
});