Лекция 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__ | автоматически, если новое исключение возникло внутри except | During handling of the above exception, another exception occurred |
__cause__ | явно, при raise ... from err | The 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")
@dataclassclass 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 # откат при ошибке raise4.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.
Вопросы для самопроверки
- Почему кастомные исключения наследуют от
Exception, а не отBaseException? Какие исключения нельзя перехватывать «случайно»? - В чём преимущество иерархии доменных исключений перед использованием только встроенных типов?
- Чем отличается хранение данных об ошибке в атрибутах от передачи их в тексте сообщения?
- В чём разница между
__cause__и__context__? Когда заполняется каждый? - Что делает
raise NewError() from errи чем это лучше простогоraise? - Когда уместно подавить цепочку через
from None? - Чем
except*отличается от обычногоexcept? Может ли сработать несколько блоковexcept*для одной группы? - Как реализовать «группу исключений» в Python до версии 3.11?
- Как распределяется обработка ошибок между слоями репозиторий → сервис → представление в примере из лекции?
- Назовите три антипаттерна обработки исключений и их корректные альтернативы.