Ошибки и исключения: как падать красиво

Ошибки - это часть жизни. Вопрос: кто их увидит и насколько быстро поймёт, что случилось.

В Go ошибки - это значения, а не исключения. Это фундаментальное решение дизайна языка: ты не можешь «случайно проигнорировать» ошибку - компилятор напомнит о неиспользованной переменной. В PHP подход другой - исключения через try/catch.

Три правила обработки ошибок

  1. Ошибка должна быть понятной - из текста ясно, что произошло
  2. Добавляй контекст - где произошла ошибка и при каких условиях
  3. Не глотай ошибки молча - _ = doSomething() - худшее, что можно сделать

Sentinel errors: именованные ошибки

Sentinel error - это предопределённая переменная (Go) или класс исключения (PHP), по которым можно делать проверки.

// Объявление sentinel errors
var (
    ErrNotFound     = errors.New("not found")
    ErrUnauthorized = errors.New("unauthorized")
    ErrForbidden    = errors.New("forbidden")
)

// Использование
func GetUser(ctx context.Context, id int) (*User, error) {
    user, err := repo.FindByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get user %d: %w", id, err)
    }
    if user == nil {
        return nil, ErrNotFound
    }
    return user, nil
}

// Проверка через errors.Is
user, err := GetUser(ctx, 42)
if errors.Is(err, ErrNotFound) {
    http.Error(w, "user not found", 404)
}

В Go ошибки - это значения: возвращаются вторым параметром, проверяются errors.Is / errors.As, оборачиваются %w.

// Объявление sentinel-исключений (по классу)
final class NotFoundException extends \RuntimeException {}
final class UnauthorizedException extends \RuntimeException {}
final class ForbiddenException extends \RuntimeException {}

// Использование
public function getUser(int $id): User
{
    try {
        $user = $this->repo->findById($id);
    } catch (Throwable $e) {
        throw new \RuntimeException("get user {$id} failed", previous: $e);
    }
    if ($user === null) {
        throw new NotFoundException("user {$id} not found");
    }
    return $user;
}

// Проверка через try/catch
try {
    $user = $service->getUser(42);
} catch (NotFoundException $e) {
    return new JsonResponse(['error' => 'user not found'], 404);
}

В PHP ошибки - это исключения: проверяются catch по типу, оборачиваются через previous, идиоматично кидать конкретный класс на каждый вид сбоя.

// Объявление sentinel-классов с causes-цепочкой
class NotFoundError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = 'NotFoundError';
  }
}
class UnauthorizedError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = 'UnauthorizedError';
  }
}
class ForbiddenError extends Error {
  constructor(message, options) {
    super(message, options);
    this.name = 'ForbiddenError';
  }
}

// Использование
async function getUser(id) {
  let user;
  try {
    user = await repo.findById(id);
  } catch (e) {
    throw new Error(`get user ${id} failed`, { cause: e });
  }
  if (user == null) {
    throw new NotFoundError(`user ${id} not found`);
  }
  return user;
}

// Проверка через instanceof
try {
  const user = await getUser(42);
} catch (e) {
  if (e instanceof NotFoundError) {
    return res.status(404).json({ error: 'user not found' });
  }
  throw e;
}

В JS ошибки - это исключения с instanceof-проверками: для оборачивания используется new Error(msg, { cause: e }) (ES2022), а на каждый класс ошибок - отдельный extends Error.

Sentinel errors хороши для стандартных ситуаций: «не найдено», «нет доступа», «конфликт». Не создавай sentinel для каждой мелочи - иначе получишь зоопарк.

Custom error types: когда нужен контекст

Когда простого текста недостаточно - создай свой тип ошибки:

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s - %s", e.Field, e.Message)
}

// Использование
func ValidateAge(age int) error {
    if age < 0 || age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "must be between 0 and 150",
        }
    }
    return nil
}

// Проверка типа через errors.As
var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Printf("поле %s: %s\n", ve.Field, ve.Message)
}
<?php
declare(strict_types=1);

// PHP-эквивалент: typed exception с публичными readonly-полями для контекста
final class ValidationException extends \DomainException
{
    public function __construct(
        public readonly string $field,
        public readonly string $reason,
    ) {
        parent::__construct("validation: {$field} - {$reason}");
    }
}

final class AgeValidator
{
    public function validate(int $age): void
    {
        if ($age < 0 || $age > 150) {
            throw new ValidationException(
                field: 'age',
                reason: 'must be between 0 and 150',
            );
        }
    }
}

// Проверка типа через instanceof (аналог errors.As)
try {
    $validator->validate($age);
} catch (ValidationException $e) {
    echo "поле {$e->field}: {$e->reason}\n";
}
`errors.Is(err, target)` - проверяет **конкретное значение** (sentinel). `errors.As(err, &target)` - проверяет **тип** и извлекает данные. Оба умеют ходить по цепочке оборачивания (`%w`).

Wrapping: оборачивание ошибок через %w

fmt.Errorf с глаголом %w оборачивает ошибку, сохраняя всю цепочку:

// Слой repository
func (r *UserRepo) GetByID(ctx context.Context, id int) (*User, error) {
    var user User
    err := r.db.WithContext(ctx).First(&user, id).Error
    if err != nil {
        return nil, fmt.Errorf("user_repo.GetByID(%d): %w", id, err)
    }
    return &user, nil
}

// Слой use case
func (uc *ProfileUC) GetProfile(ctx context.Context, id int) (*Profile, error) {
    user, err := uc.userRepo.GetByID(ctx, id)
    if err != nil {
        return nil, fmt.Errorf("get profile: %w", err)
    }
    return toProfile(user), nil
}

// В логе:
// get profile: user_repo.GetByID(42): record not found
<?php
declare(strict_types=1);

// PHP-эквивалент wrapping через previous-параметр. Цепочка строится так же:
final class UserRepository
{
    public function __construct(private readonly \PDO $db) {}

    public function getById(int $id): User
    {
        try {
            $stmt = $this->db->prepare('SELECT * FROM users WHERE id = :id');
            $stmt->execute(['id' => $id]);
            $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        } catch (\PDOException $e) {
            throw new \RuntimeException(
                "user_repo.getById({$id})",
                previous: $e,
            );
        }
        if ($row === false) {
            throw new NotFoundException("user {$id} not found");
        }
        return User::fromRow($row);
    }
}

final class ProfileUseCase
{
    public function __construct(private readonly UserRepository $users) {}

    public function getProfile(int $id): Profile
    {
        try {
            $user = $this->users->getById($id);
        } catch (\Throwable $e) {
            throw new \RuntimeException('get profile', previous: $e);
        }
        return Profile::fromUser($user);
    }
}

// При логировании Monolog раскрутит chain через ->getPrevious():
// get profile -> user_repo.getById(42) -> SQLSTATE[...] not found

Каждый слой добавляет свой контекст. В результате ошибка читается как стек вызовов - от бизнес-операции до конкретной причины.

Когда какой глагол:
──────────────────────────────────────────
%w - оборачивай, когда вызывающий код
      может проверить тип через Is/As
%v - когда хочешь скрыть внутреннюю
      реализацию (на границе API)

panic vs error

error                              panic
─────────────────────────────────  ─────────────────────────────────
Ожидаемая ситуация                 Баг в программе
«Файл не найден»                   «Индекс за пределами массива»
Вызывающий код обрабатывает        Программа падает (или recover)
Штатный поток                      Только init() и Must*-функции
// error: ожидаемая ситуация - файл может не существовать
func LoadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("load config %s: %w", path, err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("parse config: %w", err)
    }
    return &cfg, nil
}

// panic: нарушен инвариант, который не должен нарушаться при старте
func MustParseTemplate(name string) *template.Template {
    t, err := template.ParseFiles(name)
    if err != nil {
        panic(fmt.Sprintf("parse template %s: %v", name, err))
    }
    return t
}
<?php
declare(strict_types=1);

// Recoverable: \Exception для ожидаемых ситуаций
final class ConfigLoader
{
    public function load(string $path): Config
    {
        $data = @file_get_contents($path);
        if ($data === false) {
            throw new \RuntimeException("load config {$path} failed");
        }
        try {
            $decoded = json_decode($data, associative: true, flags: JSON_THROW_ON_ERROR);
        } catch (\JsonException $e) {
            throw new \RuntimeException('parse config', previous: $e);
        }
        return Config::fromArray($decoded);
    }
}

// Unrecoverable: \Error для нарушенных инвариантов на старте
final class TemplateRegistry
{
    public static function mustLoad(string $name): \Twig\TemplateWrapper
    {
        try {
            return self::twig()->load($name);
        } catch (\Throwable $e) {
            // \Error не должен ловиться обычным catch(\Exception),
            // что и подразумевается для «программа должна упасть»
            throw new \Error("parse template {$name} at boot", previous: $e);
        }
    }
}
Используй `panic` в `init()` и `Must*`-обёртках при старте приложения. В runtime-коде - всегда `error`. Библиотека, которая паникует - плохая библиотека (это [нарушение LSP](../solid/04-lsp.md): клиент ждёт `error`, а получает падение).

Антипаттерны обработки ошибок

Глотание ошибки

// Плохо: ошибка исчезла бесследно
result, _ := doSomething()

// Хорошо: обработай или прокинь
result, err := doSomething()
if err != nil {
    return fmt.Errorf("do something: %w", err)
}
<?php
declare(strict_types=1);

// Плохо: silence operator @ или пустой catch
$result = @doSomething(); // подавляет любые warnings/errors

try {
    $result = doSomething();
} catch (\Throwable $e) {
    // тихо проглатываем - ничего не залогировано, ничего не возвращено
}

// Хорошо: либо обработай конкретно, либо пробрось наверх
try {
    $result = doSomething();
} catch (NotFoundException $e) {
    return null; // явно решили, что для этого кейса null - валидный исход
} catch (\Throwable $e) {
    throw new \RuntimeException('do something', previous: $e);
}

Логирование + возврат (дубликаты в логах)

// Плохо: ошибка залогирована И возвращена
if err != nil {
    log.Printf("error: %v", err)
    return err  // вызывающий тоже залогирует → дубликат
}

// Хорошо: нижние слои только возвращают, верхний - логирует
if err != nil {
    return fmt.Errorf("operation X: %w", err)
}
<?php
declare(strict_types=1);

// Плохо: лог + re-throw в одном месте, верхний слой логирует ещё раз
final class BadRepository
{
    public function __construct(private readonly LoggerInterface $logger) {}

    public function save(Entity $e): void
    {
        try {
            $this->doSave($e);
        } catch (\Throwable $err) {
            $this->logger->error('save failed', ['err' => $err->getMessage()]);
            throw $err; // дубликат в логах от верхнего exception handler
        }
    }
}

// Хорошо: нижние слои только wrap + throw, лог на границе HTTP-контроллера
final class GoodRepository
{
    public function save(Entity $e): void
    {
        try {
            $this->doSave($e);
        } catch (\Throwable $err) {
            throw new \RuntimeException('save entity', previous: $err);
        }
    }
}
// Логирование делает Symfony ExceptionListener один раз - с полным trace

Обобщённые сообщения

// Плохо: как сказать врачу «мне плохо»
return errors.New("something went wrong")

// Хорошо: конкретика
return fmt.Errorf("create order for user %d: item %d out of stock",
    userID, itemID)
<?php
declare(strict_types=1);

// Плохо: текст без идентификаторов - дебажить нечем
throw new \RuntimeException('something went wrong');

// Хорошо: typed exception + конкретные id
final class OutOfStockException extends \DomainException
{
    public function __construct(
        public readonly int $userId,
        public readonly int $itemId,
    ) {
        parent::__construct(
            "create order for user {$userId}: item {$itemId} out of stock"
        );
    }
}

throw new OutOfStockException(userId: $userId, itemId: $itemId);

Мини-задание

  • Найди в проекте _ = ... - замени на явную обработку
  • Проверь, что ошибки логируются с контекстом (id, endpoint, операция)
  • Создай один sentinel error и один custom error type в своём проекте
  • Убедись, что на границе API внутренние детали ошибок не утекают клиенту

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.