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>
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sms\Providers;
|
||||
|
||||
use App\Services\Sms\SmsConnectionBreaker;
|
||||
use App\Services\Sms\SmsDeliveryPayloadParser;
|
||||
use App\Services\Sms\SmsDeliveryReport;
|
||||
use App\Services\Sms\SmsDeliveryReporter;
|
||||
@@ -13,6 +14,7 @@ use App\Services\Sms\SmsProvider;
|
||||
use App\Services\Sms\SmsSendException;
|
||||
use App\Services\Sms\SmsSendResult;
|
||||
use Carbon\CarbonImmutable;
|
||||
use GuzzleHttp\Handler\CurlHandler;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
@@ -34,6 +36,12 @@ use Throwable;
|
||||
*/
|
||||
final class MtsSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryReporter, SmsProvider
|
||||
{
|
||||
/** Общий обработчик curl: внутри него живут открытые соединения. */
|
||||
private ?CurlHandler $handler = null;
|
||||
|
||||
/** Предохранитель канала — общий на все вызовы объекта. */
|
||||
private ?SmsConnectionBreaker $breaker = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $token,
|
||||
private readonly array $serves,
|
||||
@@ -43,6 +51,45 @@ final class MtsSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryRepor
|
||||
private readonly ?string $connectTo = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Общий обработчик curl на всю жизнь объекта.
|
||||
*
|
||||
* 🔴 Соединения живут ВНУТРИ него: пока обработчик один, запросы идут по уже
|
||||
* открытому соединению, а не открывают новое на каждое сообщение. Прежнее
|
||||
* поведение (новое соединение на каждую смс) защита оператора считала атакой.
|
||||
*
|
||||
* 🪤 Подставляем именно обработчик, а НЕ готовый клиент: Laravel кладёт свою
|
||||
* подделку сети слоем выше, поэтому Http::fake в тестах продолжает работать.
|
||||
* Подмена клиента целиком её бы отключила — и тесты пошли бы в интернет.
|
||||
*/
|
||||
public function httpHandler(): CurlHandler
|
||||
{
|
||||
return $this->handler ??= new CurlHandler;
|
||||
}
|
||||
|
||||
private function breaker(): SmsConnectionBreaker
|
||||
{
|
||||
return $this->breaker ??= new SmsConnectionBreaker($this->key());
|
||||
}
|
||||
|
||||
/**
|
||||
* Молчим, если предохранитель разомкнут: в сеть не идём вовсе.
|
||||
*
|
||||
* @throws SmsSendException
|
||||
*/
|
||||
private function otkazEsliMolchim(): void
|
||||
{
|
||||
if (! $this->breaker()->isOpen()) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new SmsSendException(
|
||||
'МТС недоступен: канал приостановлен после серии обрывов, осталось '
|
||||
.$this->breaker()->pauseLeftSeconds().' с',
|
||||
terminal: false,
|
||||
);
|
||||
}
|
||||
|
||||
public function key(): string
|
||||
{
|
||||
return 'mts';
|
||||
@@ -63,8 +110,11 @@ final class MtsSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryRepor
|
||||
// Имя отправителя (naming): из сообщения (per-channel sender из БД), иначе дефолт канала.
|
||||
$naming = $message->senderName !== '' ? $message->senderName : $this->naming;
|
||||
|
||||
$this->otkazEsliMolchim();
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->token)
|
||||
->setHandler($this->httpHandler())
|
||||
->withOptions($this->httpOptions())
|
||||
->post(rtrim($this->baseUrl, '/').'/mcom/messageManagement/messages', [
|
||||
'submits' => [
|
||||
@@ -73,9 +123,15 @@ final class MtsSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryRepor
|
||||
'naming' => $naming,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->breaker()->noteFailure();
|
||||
|
||||
throw new SmsSendException('МТС недоступен: '.$e->getMessage(), terminal: false);
|
||||
}
|
||||
|
||||
// Ответ получен — сеть жива, счётчик обрывов обнуляем. Дальнейшие отказы
|
||||
// (нет имени, неверный номер) к соединению отношения не имеют.
|
||||
$this->breaker()->noteSuccess();
|
||||
|
||||
// 5xx — сбой оператора/моста, повтор осмыслен; иной не-2xx — наша ошибка.
|
||||
if ($response->serverError()) {
|
||||
throw new SmsSendException('МТС вернул '.$response->status(), terminal: false);
|
||||
@@ -139,16 +195,23 @@ final class MtsSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryRepor
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->otkazEsliMolchim();
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->token)
|
||||
->setHandler($this->httpHandler())
|
||||
->withOptions($this->httpOptions())
|
||||
->get(rtrim($this->baseUrl, '/').'/mcom/messageManagement/messages/status', [
|
||||
'messageIDs' => implode(',', $providerMessageIds),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->breaker()->noteFailure();
|
||||
|
||||
throw new SmsSendException('МТС недоступен: '.$e->getMessage(), terminal: false);
|
||||
}
|
||||
|
||||
$this->breaker()->noteSuccess();
|
||||
|
||||
// Судьбу всегда можно переспросить позже — значит ЛЮБОЙ отказ здесь
|
||||
// не окончательный, в отличие от отправки. Следующий заход спросит снова.
|
||||
if (! $response->successful()) {
|
||||
@@ -277,19 +340,36 @@ final class MtsSmsProvider implements SmsDeliveryPayloadParser, SmsDeliveryRepor
|
||||
}
|
||||
|
||||
/**
|
||||
* Опции Guzzle. Если задан мост ($connectTo) — маршрутизируем api.mts.ru:443
|
||||
* на локальный порт туннеля, не трогая SNI/сертификат/Host.
|
||||
* Опции Guzzle.
|
||||
*
|
||||
* 🔴 Короткое ожидание соединения (3 с) — намеренно: если адрес в чёрном
|
||||
* списке, длинное ожидание держит воркер и плодит повторные стуки, которыми
|
||||
* блокировка и продлевается.
|
||||
*
|
||||
* Своё имя клиента (User-Agent) — чтобы защита оператора видела известного
|
||||
* корпоративного клиента, а не безымянного робота.
|
||||
*
|
||||
* Если задан мост ($connectTo) — маршрутизируем api.mts.ru:443 на локальный
|
||||
* порт туннеля, не трогая SNI/сертификат/Host.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function httpOptions(): array
|
||||
public function httpOptions(): array
|
||||
{
|
||||
$options = [
|
||||
'connect_timeout' => 3,
|
||||
'timeout' => 15,
|
||||
'headers' => ['User-Agent' => 'LiderraSms/1.0 (+https://liderra.ru)'],
|
||||
];
|
||||
|
||||
if ($this->connectTo === null || $this->connectTo === '') {
|
||||
return [];
|
||||
return $options;
|
||||
}
|
||||
|
||||
$host = parse_url($this->baseUrl, PHP_URL_HOST) ?: 'api.mts.ru';
|
||||
|
||||
return ['curl' => [CURLOPT_CONNECT_TO => [$host.':443:'.$this->connectTo]]];
|
||||
$options['curl'] = [CURLOPT_CONNECT_TO => [$host.':443:'.$this->connectTo]];
|
||||
|
||||
return $options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Sms;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Предохранитель канала: после серии ОБРЫВОВ подряд перестаём стучаться вовсе.
|
||||
*
|
||||
* 🔴 Зачем. Защита оператора (у МТС — Qrator) считает не запросы, а попытки
|
||||
* ОТКРЫТЬ соединение. Каждая неудачная попытка — это ещё семь безответных стуков
|
||||
* от нашего сервера, и они же продлевают блокировку: чем упорнее долбим, тем
|
||||
* дольше нас держат в чёрном списке (замеры 05-07.08.2026, заявка RUMAAS-73856).
|
||||
* Поэтому после $limit обрывов подряд канал замолкает на $pauseSeconds.
|
||||
*
|
||||
* Считаем ТОЛЬКО обрывы связи. Отказ по существу («нет имени отправителя»,
|
||||
* «неверный номер») — это живой ответ, сеть в порядке, предохранитель не трогаем.
|
||||
*/
|
||||
final class SmsConnectionBreaker
|
||||
{
|
||||
/** Сколько обрывов подряд размыкают предохранитель. */
|
||||
public const LIMIT = 5;
|
||||
|
||||
/** На сколько секунд канал замолкает после размыкания. */
|
||||
public const PAUSE_SECONDS = 600;
|
||||
|
||||
/** Окно, за которое копятся обрывы (после тишины счётчик истекает сам). */
|
||||
private const WINDOW_SECONDS = 300;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $channel,
|
||||
private readonly int $limit = self::LIMIT,
|
||||
private readonly int $pauseSeconds = self::PAUSE_SECONDS,
|
||||
) {}
|
||||
|
||||
/** Разомкнут ли предохранитель прямо сейчас (в сеть не ходим). */
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return Cache::has($this->pauseKey());
|
||||
}
|
||||
|
||||
/** Сколько секунд осталось молчать. */
|
||||
public function pauseLeftSeconds(): int
|
||||
{
|
||||
$until = Cache::get($this->pauseKey());
|
||||
|
||||
return is_int($until) ? max(0, $until - time()) : 0;
|
||||
}
|
||||
|
||||
/** Записать обрыв связи; на $limit-м подряд — размыкаем. */
|
||||
public function noteFailure(): void
|
||||
{
|
||||
$bylo = (int) Cache::get($this->failureKey(), 0);
|
||||
$stalo = $bylo + 1;
|
||||
|
||||
if ($stalo >= $this->limit) {
|
||||
Cache::put($this->pauseKey(), time() + $this->pauseSeconds, $this->pauseSeconds);
|
||||
Cache::forget($this->failureKey());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::put($this->failureKey(), $stalo, self::WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
/** Достучались — счётчик обнуляем, паузу снимаем. */
|
||||
public function noteSuccess(): void
|
||||
{
|
||||
Cache::forget($this->failureKey());
|
||||
Cache::forget($this->pauseKey());
|
||||
}
|
||||
|
||||
private function failureKey(): string
|
||||
{
|
||||
return 'sms:breaker:'.$this->channel.':failures';
|
||||
}
|
||||
|
||||
private function pauseKey(): string
|
||||
{
|
||||
return 'sms:breaker:'.$this->channel.':pause_until';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?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);
|
||||
});
|
||||
Reference in New Issue
Block a user