diff --git a/app/app/Services/Sms/Providers/MtsSmsProvider.php b/app/app/Services/Sms/Providers/MtsSmsProvider.php index 534b1e4b..cdc5c34d 100644 --- a/app/app/Services/Sms/Providers/MtsSmsProvider.php +++ b/app/app/Services/Sms/Providers/MtsSmsProvider.php @@ -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 */ - 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; } } diff --git a/app/app/Services/Sms/SmsConnectionBreaker.php b/app/app/Services/Sms/SmsConnectionBreaker.php new file mode 100644 index 00000000..257f3a63 --- /dev/null +++ b/app/app/Services/Sms/SmsConnectionBreaker.php @@ -0,0 +1,84 @@ +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'; + } +} diff --git a/app/tests/Unit/Sms/MtsSmsProviderConnectionTest.php b/app/tests/Unit/Sms/MtsSmsProviderConnectionTest.php new file mode 100644 index 00000000..61dd6c61 --- /dev/null +++ b/app/tests/Unit/Sms/MtsSmsProviderConnectionTest.php @@ -0,0 +1,184 @@ + 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); +});