Обработка ошибок
Ошибку не нужно ловить и превращать в ответ вручную. Вы бросаете исключение из любого места — контроллера, сервиса, middleware, — а фреймворк сам подбирает HTTP-код, формат ответа и уровень записи в лог.
Что такое обработка ошибок и зачем
Обработка ошибок — превращение сбоя (не найдено, нет прав, отвалилась база) в корректный HTTP-ответ.
Проблема. Возвращать ошибку значением неудобно: результат приходится
протаскивать через все слои, а каждая промежуточная функция обязана его проверить и
передать выше. Обычно так и не делают — оборачивают вызовы в try/catch и
собирают ответ на месте. Получается разнобой: один эндпоинт отвечает 404, другой
на ту же ситуацию — 200 с {"error": ...}; где-то в тело утёк стектрейс; в
логах ожидаемый 404 записан рядом с падением базы, и по уровню их не отличить.
Решение. Бросьте исключение там, где обнаружили проблему. Фреймворк перехватит его на верхнем уровне, выберет код, согласует формат с клиентом и запишет лог подходящего уровня. Промежуточные слои о сбое знать не обязаны, а ответы получаются одинаковыми по всему приложению.
Как бросать
use Flytachi\Winter\Base\HttpCode;
use Flytachi\Winter\Kernel\Http\Response\ResponseException;
throw new ResponseException('User not found', HttpCode::NOT_FOUND);
// то же самое, но выражением — удобно в тернарнике или ?:
ResponseException::throw('Forbidden', HttpCode::FORBIDDEN);
// с дополнительным заголовком
throw new ResponseException('Rate limit exceeded', HttpCode::TOO_MANY_REQUESTS)
->withHeader('Retry-After', '60');Ловить это не нужно нигде: исключение поднимется из любой глубины — из сервиса, из
репозитория, из before() у middleware — и станет ответом.
Бросать удобнее, чем возвращать
return ResponseEntity::notFound() из глубины метода означает, что вызывающий код
обязан этот ответ распознать и передать дальше. Исключение проходит сквозь слои
само, поэтому проверка «нашли ли запись» пишется одной строкой там, где она по
смыслу и находится.
Какое исключение выбрать
Тип задаёт код по умолчанию и уровень лога. Код можно переопределить вторым аргументом, уровень — нет.
| Исключение | Код | Уровень лога | Когда бросать |
|---|---|---|---|
ResponseException |
400 | по коду | Любая ожидаемая HTTP-ошибка |
ClientError |
409 | warning |
Клиент попросил невозможное по правилам предметной области |
ServerError |
500 | error |
Сбой на нашей стороне: внешний сервис, диск, сеть |
Error |
520 | по коду | Когда на месте неизвестно, чья это вина |
KernelError |
500 | emergency |
Нарушен инвариант самого ядра |
MiddlewareException |
401 | по коду | Отказ из middleware |
ValidationException |
422 | по коду | Провал валидации — бросается сам |
«По коду» значит: 5xx пишется как error, 4xx — как warning. Так падение
шлюза и обычный 404 не попадают в один поток.
use Flytachi\Winter\Kernel\Exception\ClientError;
use Flytachi\Winter\Kernel\Exception\ServerError;
throw new ClientError('Email already taken'); // 409
ClientError::throw('Slot is booked', HttpCode::UNPROCESSABLE_ENTITY);
throw new ServerError('Payment gateway timeout'); // 500Разница между ResponseException и ClientError — в намерении. Первое говорит
«ответить таким-то кодом» и живёт ближе к HTTP; второе описывает нарушение правила
предметной области и не обязано знать про коды. В сервисах и репозиториях уместнее
второе.
Обычные исключения PHP
Бросать разрешается что угодно — RuntimeException, LogicException, ошибку из
чужой библиотеки. Такое исключение тоже станет ответом, но:
- код возьмётся из
getCode(), а если это не похоже на HTTP-статус — будет500; - в лог оно пойдёт как
error, потому что уровень объявляют только исключения фреймворка.
Так что необработанное TypeError из глубины превратится в честный 500, а не в
белую страницу.
Что получит клиент
Тело собирается по заголовку Accept — так же, как у обычных ответов:
Accept |
Что уйдёт |
|---|---|
application/json, */* или заголовка нет |
JSON |
application/xml |
XML |
text/html |
Страница с кодом и сообщением |
{
"code": 404,
"message": "User not found"
}У ValidationException в теле дополнительно появляется карта errors — см.
Валидацию.
Режим отладки
При DEBUG=true в тело добавляются отладочные данные, а для Accept: text/html —
подробная страница со стектрейсом. При DEBUG=false остаются только code и
message.
Сообщение исключения видно клиенту всегда
Скрывается стектрейс, но не текст. throw new ServerError("Не удалось подключиться к db-prod-01: неверный пароль для пользователя app_rw") уедет
клиенту дословно и в проде тоже.
Пишите в сообщение то, что не жалко показать наружу, а подробности передавайте в
лог отдельно или прикладывайте исходное исключение через previous.
Свои обработчики — #[AdviceException]
Когда формат по умолчанию не подходит — нужен свой код ошибки, дополнительные поля, особый формат для партнёрского API, — заведите класс-обработчик. Регистрировать его не нужно, он находится сканом.
<?php
namespace Main\Exception;
use Flytachi\Winter\Kernel\Http\Response\AdviceException;
use Flytachi\Winter\Kernel\Http\Stereotype\ExceptionResponseBase;
#[AdviceException(DomainException::class)]
class DomainErrorResponse extends ExceptionResponseBase
{
protected function contentData(): array
{
return [
'error' => 'domain_error',
'code' => $this->throwable->getCode(),
'detail' => $this->throwable->getMessage(),
] + $this->debugData();
}
}Один обработчик может закрывать несколько типов:
#[AdviceException(NotFoundException::class, GoneException::class)]А без аргументов становится запасным — примет всё, для чего не нашлось своего:
#[AdviceException]
class FallbackErrorResponse extends ExceptionResponseBase { /* ... */ }Что можно переопределить
| Метод | За что отвечает |
|---|---|
contentData(): array |
Тело для JSON и XML |
contentHtml(): string |
Тело для Accept: text/html |
Внутри доступны:
| Что | Зачем |
|---|---|
$this->throwable |
Само исключение |
$this->httpCode |
Код, который уйдёт клиенту |
debugData() |
Отладочный блок — пуст, когда DEBUG=false |
validationRequests() |
Карта ошибок валидации; [], если исключение другое |
addHeader() |
Добавить заголовок к ответу об ошибке |
Прибавляйте debugData() к своему телу, иначе в режиме отладки вы потеряете
стектрейс именно на тех ошибках, которые чаще всего и отлаживаете.
Порядок выбора
- Обработчики с перечисленными классами — исключение проверяется на
instanceof. - Обработчик без аргументов, если такой есть.
- Поведение по умолчанию.
Не заводите пересекающиеся обработчики
Если исключение подходит сразу двум обработчикам с явными классами — скажем, один
объявлен на RuntimeException, другой на его наследника, — сработает тот, который
сканер встретил первым. Порядок обхода файлов не задан, поэтому полагаться на него
нельзя: делайте области обработчиков непересекающимися.
Ошибки вне обработчика запроса
Всё сказанное касается ошибок внутри HTTP-запроса. Сбой в фоновом процессе, демоне или консольной команде в HTTP-ответ превратить некуда — он уходит в лог по тем же уровням. Про то, куда именно пишется лог и как настроить каналы, — на странице Логирование.
Дальше
- Валидация — откуда берётся
ValidationExceptionи её422 - Middleware — отказ из
before()черезMiddlewareException - Ответы — согласование формата, общее с ошибками
- Логирование — куда попадают сообщения и уровни