Перейти к содержимому

Лекция 11. Кастомные исключения, цепочки и группы

Введение

На прошлых занятиях мы разобрали базовый механизм try / except / else / finally и встроенную иерархию исключений Python. Сегодня поднимемся на уровень выше: научимся проектировать собственные исключения предметной области, связывать их в цепочки причин и обрабатывать группы ошибок сразу.

Зачем это нужно? Хорошо спроектированные исключения — это часть публичного интерфейса вашего кода. Они говорят вызывающей стороне не просто «что-то сломалось», а что именно сломалось, где и почему. Это позволяет писать точную обработку ошибок вместо «универсального» except Exception: pass, который скрывает баги.


1. Собственные классы исключений

1.1. Базовый принцип

Кастомное исключение — это обычный класс, наследующийся от Exception (но не напрямую от BaseException!). Минимальная форма:

class ApplicationError(Exception):
"""Базовое исключение всего приложения."""
class ValidationError(ApplicationError):
"""Ошибка валидации данных."""
class DatabaseError(ApplicationError):
"""Ошибка работы с хранилищем."""

Уже это даёт большое преимущество: есть общий предок ApplicationError. Вызывающий код может ловить как конкретную ошибку (except ValidationError), так и всю категорию (except ApplicationError).

Почему своя иерархия лучше голого Exception:

  • Точность перехвата. except ValidationError ловит только то, что вы имели в виду, и не глотает случайно KeyError из-за опечатки в коде.
  • Группировка по смыслу. Базовый класс объединяет родственные ошибки, и обработчик верхнего уровня может реагировать на всю категорию.
  • Самодокументирование. Имя InsufficientFundsError говорит больше, чем ValueError("error").

1.2. Передача контекста

Самое ценное в кастомных исключениях — возможность нести структурированные данные об ошибке, а не только текст:

class BankError(Exception):
"""Базовое исключение банковских операций."""
class InsufficientFundsError(BankError):
def __init__(self, balance: float, amount: float):
self.balance = balance
self.amount = amount
self.deficit = amount - balance # удобный производный атрибут
super().__init__(
f"Недостаточно средств: баланс {balance}, требуется {amount}"
)

Теперь обработчик работает с данными программно, а не парсит строку:

try:
account.withdraw(500)
except InsufficientFundsError as e:
print(f"Не хватает {e.deficit} рублей") # доступ к атрибуту
except BankError as e:
print(f"Банковская ошибка: {e}") # ловим всё остальное

Важный момент: всегда вызывайте super().__init__(message). Так сообщение попадёт в args, корректно отобразится в трейсбеке и при str(e).

Для крупных систем удобно добавлять машиночитаемый код ошибки — он не зависит от формулировки сообщения и пригодится в логах и API:

class UserSystemError(Exception):
def __init__(self, message: str, error_code: str = "UNKNOWN"):
super().__init__(message)
self.message = message
self.error_code = error_code
def __str__(self) -> str:
return f"[{self.error_code}] {self.message}"

2. Цепочки исключений

2.1. Проблема «потери причины»

Часто низкоуровневая ошибка (например, sqlite3.Error) должна быть «переведена» на язык предметной области. Наивная обёртка теряет исходную причину:

try:
cursor.execute(...)
except sqlite3.Error:
raise DatabaseError("Не удалось получить пользователя")
# Трейсбек больше НЕ показывает, что именно сломалось в БД

Это плохо: при отладке мы видим только DatabaseError, а настоящая первопричина (битый файл, заблокированная таблица) исчезла.

2.2. raise ... from ...

Конструкция from явно связывает новое исключение с причиной:

try:
cursor.execute(...)
except sqlite3.Error as e:
raise DatabaseError("Не удалось получить пользователя") from e

Теперь трейсбек покажет обе ошибки: сначала исходную sqlite3.OperationalError, затем строку «The above exception was the direct cause of the following exception» и поверх неё — наш DatabaseError.

2.3. __cause__ и __context__

Python хранит связь между исключениями в двух атрибутах:

АтрибутКогда заполняетсяСообщение в трейсбеке
__context__автоматически, если новое исключение возникло внутри exceptDuring handling of the above exception, another exception occurred
__cause__явно, при raise ... from errThe above exception was the direct cause of the following exception

То есть __context__ ставится неявно всегда, а __cause__ — только когда вы сами написали from. Из кода до причины можно добраться через e.__cause__ (вернёт исходное sqlite3.OperationalError) — это удобно для логирования.

2.4. Подавление цепочки: from None

Иногда исходная ошибка — деталь реализации, которую не нужно показывать наружу. Тогда цепочку можно подавить:

try:
return self._storage[user_id]
except KeyError:
raise UserNotFoundError(user_id) from None # скрываем внутренний KeyError

Правило: сохраняйте причину (from e) по умолчанию. Подавляйте (from None) только осознанно, когда внутренняя ошибка действительно не несёт пользы для диагностики.


3. Группы исключений (Python 3.11+)

3.1. Зачем нужны группы

Классический raise сообщает об одной ошибке и останавливается. Но бывают задачи, где ошибок может быть много сразу:

  • валидация формы — хочется показать пользователю все проблемы, а не первую попавшуюся;
  • параллельные задачи — несколько воркеров упали независимо;
  • пакетная обработка — часть элементов не прошла.

Для этого в Python 3.11 появился класс ExceptionGroup и синтаксис except*.

3.2. ExceptionGroup

def validate_user_data(data: dict) -> None:
errors = []
if '@' not in data.get('email', ''):
errors.append(ValueError("Невалидный email"))
if not isinstance(data.get('age'), int):
errors.append(TypeError("Возраст должен быть целым числом"))
if not data.get('name', '').strip():
errors.append(ValueError("Имя не может быть пустым"))
if errors:
raise ExceptionGroup("Ошибки валидации", errors)

Группа несёт список вложенных исключений в атрибуте .exceptions:

try:
validate_user_data({"email": "bad", "age": -5, "name": ""})
except* ValueError as eg:
print(f"Ошибки значений: {len(eg.exceptions)}")
except* TypeError as eg:
print(f"Ошибки типов: {len(eg.exceptions)}")

3.3. Синтаксис except*

Ключевое отличие except* от обычного except:

  • обычный except ловит первое подходящее исключение целиком;
  • except* «расщепляет» группу: каждый блок забирает из неё только подходящие по типу исключения, а остальные продолжают распространяться.

Поэтому может сработать сразу несколько блоков except* для одной группы. Необработанная часть группы автоматически пробрасывается дальше.

3.4. Совместимость со старыми версиями

До Python 3.11 группы эмулируют собственным классом, который хранит список ошибок:

class MultipleValidationErrors(Exception):
"""Множественные ошибки валидации (для Python < 3.11)."""
def __init__(self, errors: list[Exception]):
self.errors = errors
body = "\n".join(f"{type(e).__name__}: {e}" for e in errors)
super().__init__(f"Найдено {len(errors)} ошибок:\n{body}")
def by_type(self, error_type: type) -> list[Exception]:
return [e for e in self.errors if isinstance(e, error_type)]

Решение проще ExceptionGroup, но не поддерживает except* и не расщепляет группу автоматически — разбор остаётся на вас.


4. Практика: обработка по слоям

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

4.1. Иерархия ошибок и модель

from dataclasses import dataclass
class UserSystemError(Exception):
def __init__(self, message: str, error_code: str = "UNKNOWN"):
super().__init__(message)
self.message = message
self.error_code = error_code
class UserNotFoundError(UserSystemError):
def __init__(self, user_id: str):
self.user_id = user_id
super().__init__(f"Пользователь '{user_id}' не найден", "USER_NOT_FOUND")
class InvalidUserDataError(UserSystemError):
def __init__(self, field: str, value, reason: str):
self.field, self.value, self.reason = field, value, reason
super().__init__(f"Поле '{field}': {reason}", "INVALID_DATA")
class DatabaseError(UserSystemError):
def __init__(self, operation: str):
super().__init__(f"Ошибка БД при операции '{operation}'", "DATABASE_ERROR")
@dataclass
class User:
id: str
email: str
age: int
def validate(self) -> None:
if '@' not in self.email:
raise InvalidUserDataError("email", self.email, "нет символа '@'")
if not isinstance(self.age, int) or self.age < 0:
raise InvalidUserDataError("age", self.age, "некорректный возраст")

4.2. Репозиторий — перевод технических ошибок

class UserRepository:
def __init__(self):
self._storage: dict[str, User] = {}
def get(self, user_id: str) -> User:
try:
return self._storage[user_id]
except KeyError:
raise UserNotFoundError(user_id) from None # KeyError — деталь
def save(self, user: User) -> User:
try:
user.validate()
self._storage[user.id] = user
return user
except InvalidUserDataError:
raise # доменную — пробросить
except Exception as e:
raise DatabaseError("save") from e # прочее — обернуть

Ключевой приём: свои доменные исключения пробрасываем как есть (raise), а любые неожиданные оборачиваем в DatabaseError, сохраняя причину через from e.

4.3. Сервис — бизнес-логика и откат

class UserService:
def __init__(self, repository: UserRepository):
self.repository = repository
def register(self, user_id: str, email: str, age: int) -> User:
return self.repository.save(User(user_id, email, age))
def change_email(self, user_id: str, new_email: str) -> User:
user = self.repository.get(user_id)
old_email = user.email
try:
user.email = new_email
return self.repository.save(user)
except InvalidUserDataError:
user.email = old_email # откат при ошибке
raise

4.4. Представление — реакция на ошибки

def view_register(service: UserService, **data) -> None:
try:
user = service.register(**data)
print(f"OK: зарегистрирован {user.id}")
except InvalidUserDataError as e:
print(f"Проверьте поле '{e.field}': {e.reason}")
except UserNotFoundError as e:
print(f"Не найдено: {e.message}")
except UserSystemError as e:
print(f"Системная ошибка [{e.error_code}]: {e.message}")

Только верхний слой (представление) принимает решение, что показать пользователю. Нижние слои либо обрабатывают то, что относится к их зоне ответственности, либо пробрасывают ошибку наверх с сохранённым контекстом.


5. Антипаттерны и хорошие практики

АнтипаттернКак правильно
except: без типа (ловит даже KeyboardInterrupt/SystemExit)Ловить Exception или конкретный тип
except Exception: pass — «проглатывание» ошибкиЗалогировать и обработать либо пробросить raise
Один except Exception на все случаиОтдельные блоки под разные типы ошибок
raise NewError(...) без from — теряется причинаraise NewError(...) from err
raise Exception("Error") — неинформативноСвой класс с контекстом: ConfigNotFoundError(path)
Исключения как goto / для обычного ветвленияОбычные if/return для штатной логики
Бизнес-данные парсятся из текста сообщенияХранить данные в атрибутах исключения
print ошибки в библиотечном кодеПробрасывать исключение, решение — за вызывающим

Подробнее о двух самых частых.

Не «глотайте» исключения. Пустой except превращает баги в «молчаливые» — программа продолжает работать с некорректным состоянием, а вы узнаёте об этом лишь во время инцидента:

# ❌ Плохо: ошибка исчезает бесследно
try:
risky_operation()
except Exception:
pass
# ✅ Хорошо: логируем и/или пробрасываем
try:
risky_operation()
except ConnectionError as e:
logger.warning("Сбой соединения: %s", e)
use_fallback()
except Exception:
logger.exception("Неожиданная ошибка")
raise

Исключения — не средство управления потоком. Они предназначены для исключительных ситуаций, дороже обычных условий и затрудняют чтение кода. Поиск элемента — это штатная логика, и он должен быть обычным циклом с return, а не try/except StopIteration.


Краткие итоги

  • Кастомные исключения наследуются от Exception и образуют иерархию доменных ошибок с общим базовым классом — это даёт точный и гибкий перехват.
  • Храните контекст ошибки в атрибутах объекта, а не только в строке сообщения; всегда вызывайте super().__init__(message).
  • При переводе ошибки на другой уровень используйте raise New() from err, чтобы сохранить причину. __context__ ставится автоматически, __cause__ — через from. Подавляйте цепочку только осознанно (from None).
  • ExceptionGroup и except* (Python 3.11+) позволяют сообщать и обрабатывать несколько ошибок сразу; для старых версий — собственный класс со списком.
  • Распределяйте ответственность по слоям: репозиторий переводит технические ошибки в доменные, сервис делает откаты, представление реагирует на ошибки.
  • Не глотайте исключения, давайте специфичные сообщения и не используйте исключения как goto.

Вопросы для самопроверки

  1. Почему кастомные исключения наследуют от Exception, а не от BaseException? Какие исключения нельзя перехватывать «случайно»?
  2. В чём преимущество иерархии доменных исключений перед использованием только встроенных типов?
  3. Чем отличается хранение данных об ошибке в атрибутах от передачи их в тексте сообщения?
  4. В чём разница между __cause__ и __context__? Когда заполняется каждый?
  5. Что делает raise NewError() from err и чем это лучше простого raise?
  6. Когда уместно подавить цепочку через from None?
  7. Чем except* отличается от обычного except? Может ли сработать несколько блоков except* для одной группы?
  8. Как реализовать «группу исключений» в Python до версии 3.11?
  9. Как распределяется обработка ошибок между слоями репозиторий → сервис → представление в примере из лекции?
  10. Назовите три антипаттерна обработки исключений и их корректные альтернативы.