Основы веб-разработки

Обработка ошибок

Ошибку не нужно ловить и превращать в ответ вручную. Вы бросаете исключение из любого места — контроллера, сервиса, middleware, — а фреймворк сам подбирает HTTP-код, формат ответа и уровень записи в лог.

Базовое ResponseExceptionСвои обработчики #[AdviceException]Формат по Accept

Что такое обработка ошибок и зачем

Обработка ошибок — превращение сбоя (не найдено, нет прав, отвалилась база) в корректный HTTP-ответ.

Проблема. Возвращать ошибку значением неудобно: результат приходится протаскивать через все слои, а каждая промежуточная функция обязана его проверить и передать выше. Обычно так и не делают — оборачивают вызовы в try/catch и собирают ответ на месте. Получается разнобой: один эндпоинт отвечает 404, другой на ту же ситуацию — 200 с {"error": ...}; где-то в тело утёк стектрейс; в логах ожидаемый 404 записан рядом с падением базы, и по уровню их не отличить.

Решение. Бросьте исключение там, где обнаружили проблему. Фреймворк перехватит его на верхнем уровне, выберет код, согласует формат с клиентом и запишет лог подходящего уровня. Промежуточные слои о сбое знать не обязаны, а ответы получаются одинаковыми по всему приложению.

Как бросать

php
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 не попадают в один поток.

php
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 Страница с кодом и сообщением
json
{
"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, — заведите класс-обработчик. Регистрировать его не нужно, он находится сканом.

main/Exception/DomainErrorResponse.php
<?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();
  }
}

Один обработчик может закрывать несколько типов:

php
#[AdviceException(NotFoundException::class, GoneException::class)]

А без аргументов становится запасным — примет всё, для чего не нашлось своего:

php
#[AdviceException]
class FallbackErrorResponse extends ExceptionResponseBase { /* ... */ }

Что можно переопределить

Метод За что отвечает
contentData(): array Тело для JSON и XML
contentHtml(): string Тело для Accept: text/html

Внутри доступны:

Что Зачем
$this->throwable Само исключение
$this->httpCode Код, который уйдёт клиенту
debugData() Отладочный блок — пуст, когда DEBUG=false
validationRequests() Карта ошибок валидации; [], если исключение другое
addHeader() Добавить заголовок к ответу об ошибке

Прибавляйте debugData() к своему телу, иначе в режиме отладки вы потеряете стектрейс именно на тех ошибках, которые чаще всего и отлаживаете.

Порядок выбора

  1. Обработчики с перечисленными классами — исключение проверяется на instanceof.
  2. Обработчик без аргументов, если такой есть.
  3. Поведение по умолчанию.

Не заводите пересекающиеся обработчики

Если исключение подходит сразу двум обработчикам с явными классами — скажем, один объявлен на RuntimeException, другой на его наследника, — сработает тот, который сканер встретил первым. Порядок обхода файлов не задан, поэтому полагаться на него нельзя: делайте области обработчиков непересекающимися.

Ошибки вне обработчика запроса

Всё сказанное касается ошибок внутри HTTP-запроса. Сбой в фоновом процессе, демоне или консольной команде в HTTP-ответ превратить некуда — он уходит в лог по тем же уровням. Про то, куда именно пишется лог и как настроить каналы, — на странице Логирование.

Дальше

  • Валидация — откуда берётся ValidationException и её 422
  • Middleware — отказ из before() через MiddlewareException
  • Ответы — согласование формата, общее с ошибками
  • Логирование — куда попадают сообщения и уровни