Ошибки и исключения: как падать красиво
Ошибки - это часть жизни. Вопрос: кто их увидит и насколько быстро поймёт, что случилось.
В Go ошибки - это значения, а не исключения. Это фундаментальное решение дизайна языка: ты не можешь «случайно проигнорировать» ошибку - компилятор напомнит о неиспользованной переменной. В PHP подход другой - исключения через try/catch.
Три правила обработки ошибок
- Ошибка должна быть понятной - из текста ясно, что произошло
- Добавляй контекст - где произошла ошибка и при каких условиях
- Не глотай ошибки молча -
_ = 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";
}
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);
}
}
}
Антипаттерны обработки ошибок
Глотание ошибки
// Плохо: ошибка исчезла бесследно
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 внутренние детали ошибок не утекают клиенту