Перейти к содержанию

Обработать ошибки слушателей

Слушатель упал. Сервис бонусов недоступен, база не ответила, пришли неожиданные данные. Вопрос не в том, случится ли это, а в том, что будет дальше.

По умолчанию роутер делает так:

  1. перехватывает исключение и записывает его в отчёт со статусом Failed;
  2. пишет его в лог роутера;
  3. вызывает следующих слушателей, как будто ничего не случилось.

Одна поломка не превращается в цепную реакцию. Дальше — как это настроить под себя.

Поймать ошибку в самом слушателе

Лучшее место для обработки ошибки — там, где понятно, что она значит. Слушатель знает, что пошло не так и что с этим делать:

final class AccrueBonuses implements ListenerInterface
{
    public static function handle(EventInterface $event): void
    {
        try {
            Container::get(BonusService::class)->accrue($event->getAttribute('order_id'));
        } catch (BonusServiceUnavailable $error) {
            Container::get(LoggerInterface::class)->warning('Бонусы не начислены', ['exception' => $error]);
        }
    }
}

Такой слушатель завершается без исключения, и в отчёте у него статус Handled. Обработка ошибок роутера — страховка для того, что слушатель не поймал, а не основной путь.

Отправить ошибки в своё место

Чтобы ошибки уходили не в лог, а в мониторинг, Sentry или чат, напишите свой обработчик. Он реализует ErrorHandlerInterface и получает исключение, событие с параметрами маршрута и класс упавшего слушателя:

use Selyusize\EventsRouter\Contract\Error\ErrorHandlerInterface;

final class EchoErrorHandler implements ErrorHandlerInterface
{
    public function handle(Throwable $error, EventInterface $event, string $listener): void
    {
        echo '    ошибка: ', $listener, ' — ', $error->getMessage(), PHP_EOL;
    }
}
$events->setErrorHandler(new EchoErrorHandler());

Свой обработчик заменяет запись в лог. Если нужно и то и другое, вызовите логгер внутри своего обработчика.

Если нужно писать ошибки в другой логгер или с другим уровнем, свой класс не нужен:

use Psr\Log\LogLevel;
use Selyusize\EventsRouter\Service\Error\PsrLoggerErrorHandler;

$events->setErrorHandler(new PsrLoggerErrorHandler($alertLogger, LogLevel::CRITICAL));

Остановиться на первой ошибке

Иногда продолжать нельзя: если не прошла оплата, отправлять письмо «спасибо за покупку» бессмысленно. Для этого есть стратегии:

use Selyusize\EventsRouter\Dispatch\ErrorStrategyEnum;
use Selyusize\EventsRouter\Event;
use Selyusize\EventsRouter\EventRouterFactory;

foreach ([ErrorStrategyEnum::Continue, ErrorStrategyEnum::Stop, ErrorStrategyEnum::Throw] as $strategy) {
    echo $strategy->name, ':', PHP_EOL;

    $events = EventRouterFactory::create()
        ->setErrorHandler(new EchoErrorHandler())
        ->setErrorStrategy($strategy);

    $events->listen('order.paid', MarkOrderPaid::class);
    $events->listen('order.paid', AccrueBonuses::class);
    $events->listen('order.paid', SendReceipt::class);

    try {
        $report = $events->dispatch(new Event('order.paid'));

        foreach ($report->getListeners() as $listener) {
            echo '    ', $listener->getListener(), ': ', $listener->getStatus()->name, PHP_EOL;
        }
    } catch (RuntimeException $error) {
        echo '    исключение из dispatch(): ', $error->getMessage(), PHP_EOL;
    }
}
Continue:
    MarkOrderPaid
    ошибка: AccrueBonuses — сервис бонусов недоступен
    SendReceipt
    MarkOrderPaid: Handled
    AccrueBonuses: Failed
    SendReceipt: Handled
Stop:
    MarkOrderPaid
    ошибка: AccrueBonuses — сервис бонусов недоступен
    MarkOrderPaid: Handled
    AccrueBonuses: Failed
    SendReceipt: Skipped
Throw:
    MarkOrderPaid
    исключение из dispatch(): сервис бонусов недоступен
Стратегия Что происходит
ErrorStrategyEnum::Continue по умолчанию: ошибка записана, остальные слушатели работают
ErrorStrategyEnum::Stop ошибка записана, остальные слушатели получают статус Skipped
ErrorStrategyEnum::Throw исключение выходит из dispatch() как есть, обработчик не вызывается

Throw подходит для тестов и для случаев, когда снаружи открыта транзакция и её нужно откатить. Ошибку обрабатывает вызывающий код, поэтому роутер её не пишет: иначе она попала бы в лог дважды.

Остановить рассылку без ошибки

Слушатель может решить, что остальным событие не нужно. Для этого событие или его payload реализует PSR-14 StoppableEventInterface. Когда isPropagationStopped() вернёт true, оставшиеся слушатели получат статус Skipped.

Флаг удобно держать в payload: это общий объект для всех копий события, поэтому изменение, сделанное одним слушателем, увидят следующие.

Что не перехватывается

Две ошибки роутер намеренно выпускает наружу при любой стратегии:

  • исключение в middleware роутера ($events->add()): оно не относится ни к одному слушателю;
  • исключение в самом обработчике ошибок: сломанный обработчик лучше заметить сразу, чем терять ошибки молча.